@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,146 @@
1
+ import chalk from 'chalk';
2
+ import { existsSync, unlinkSync, rmdirSync, readdirSync } from 'node:fs';
3
+ import { join, dirname, resolve, sep } from 'node:path';
4
+ import { readManifest } from '../lib/manifest.js';
5
+ import { resolveTargetDir, resolveManifestPath, deriveProjectRoot, assertSafePath } from '../lib/paths.js';
6
+
7
+ /**
8
+ * Run the remove command.
9
+ *
10
+ * @param {{
11
+ * targetDir?: string,
12
+ * manifestPath?: string
13
+ * }} params
14
+ * @returns {Promise<{ removed: number, skipped: number, dirsCleaned: number }>}
15
+ */
16
+ export async function runRemove(params = {}) {
17
+ const {
18
+ targetDir = resolveTargetDir({ global: false }),
19
+ manifestPath = resolveManifestPath({ global: false })
20
+ } = params;
21
+
22
+ // Read manifest
23
+ let manifest;
24
+ try {
25
+ manifest = readManifest(manifestPath);
26
+ } catch (err) {
27
+ throw new Error(`Not installed (no manifest found). Kit has not been installed here. (${err.message})`);
28
+ }
29
+
30
+ const projectRoot = deriveProjectRoot(manifest, manifestPath);
31
+ const claudeRoot = resolve(join(projectRoot, '.claude'));
32
+ const files = manifest.files || {};
33
+
34
+ let removed = 0;
35
+ let skipped = 0;
36
+ const parentDirs = new Set();
37
+
38
+ // Delete each file in manifest
39
+ for (const relPath of Object.keys(files)) {
40
+ const absPath = join(projectRoot, relPath);
41
+ // Bounds check: relPath from manifest must not escape .claude/ subtree
42
+ try {
43
+ assertSafePath(absPath, claudeRoot, `manifest key "${relPath}"`);
44
+ } catch (err) {
45
+ process.stderr.write(`Warning: Skipping unsafe path from manifest: ${err.message}\n`);
46
+ skipped++;
47
+ continue;
48
+ }
49
+ if (existsSync(absPath)) {
50
+ try {
51
+ unlinkSync(absPath);
52
+ removed++;
53
+ // Track parent directory for cleanup
54
+ parentDirs.add(dirname(absPath));
55
+ } catch (err) {
56
+ process.stderr.write(`Warning: Could not delete ${absPath}: ${err.message}\n`);
57
+ skipped++;
58
+ }
59
+ } else {
60
+ skipped++;
61
+ }
62
+ }
63
+
64
+ // Clean up empty directories (bottom-up — deepest first)
65
+ const sortedDirs = [...parentDirs].sort((a, b) => {
66
+ // More path separators = deeper
67
+ const depthA = a.split(/[/\\]/).length;
68
+ const depthB = b.split(/[/\\]/).length;
69
+ return depthB - depthA;
70
+ });
71
+
72
+ let dirsCleaned = 0;
73
+ for (const dir of sortedDirs) {
74
+ if (isEmptyDir(dir)) {
75
+ try {
76
+ rmdirSync(dir);
77
+ dirsCleaned++;
78
+ // Also try parent directories, but never delete targetDir or above
79
+ const resolvedTarget = resolve(targetDir);
80
+ let current = dirname(dir);
81
+ let prev = dir;
82
+ while (current !== prev && isEmptyDir(current)) {
83
+ const resolvedCurrent = resolve(current);
84
+ // Stop at targetDir boundary — never delete .claude/ root or anything above it
85
+ if (resolvedCurrent === resolvedTarget || !resolvedCurrent.startsWith(resolvedTarget + sep)) {
86
+ break;
87
+ }
88
+ try {
89
+ rmdirSync(current);
90
+ dirsCleaned++;
91
+ } catch {
92
+ break;
93
+ }
94
+ prev = current;
95
+ current = dirname(current);
96
+ }
97
+ } catch {
98
+ // Non-empty or permission error — skip
99
+ }
100
+ }
101
+ }
102
+
103
+ // Delete manifest itself
104
+ try {
105
+ unlinkSync(manifestPath);
106
+ } catch {
107
+ // If manifest already gone, that's fine
108
+ }
109
+
110
+ return { removed, skipped, dirsCleaned };
111
+ }
112
+
113
+ /**
114
+ * Check if a directory is empty.
115
+ * @param {string} dir
116
+ * @returns {boolean}
117
+ */
118
+ function isEmptyDir(dir) {
119
+ try {
120
+ return readdirSync(dir).length === 0;
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * CLI action handler for 'mk remove'.
128
+ * @param {{ global: boolean }} options
129
+ */
130
+ export async function removeAction(options = {}) {
131
+ const targetDir = resolveTargetDir(options);
132
+ const manifestPath = resolveManifestPath(options);
133
+
134
+ try {
135
+ process.stdout.write('Removing MyClaudeKit files...\n');
136
+ const result = await runRemove({ targetDir, manifestPath });
137
+ process.stdout.write(
138
+ chalk.green(
139
+ `\nSummary: ${result.removed} files removed, ${result.skipped} already missing, ${result.dirsCleaned} directories cleaned.\n`
140
+ )
141
+ );
142
+ } catch (err) {
143
+ process.stderr.write(chalk.red(`Error: ${err.message}\n`));
144
+ process.exit(1);
145
+ }
146
+ }
@@ -0,0 +1,221 @@
1
+ import chalk from 'chalk';
2
+ import { existsSync, unlinkSync, copyFileSync, mkdirSync, readFileSync } from 'node:fs';
3
+ import { join, dirname, resolve, sep } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { readManifest, updateManifest, diffManifest } from '../lib/manifest.js';
6
+ import { computeChecksum } from '../lib/checksum.js';
7
+ import { copyKitFiles } from '../lib/copy.js';
8
+ import { resolveTargetDir, resolveManifestPath, deriveProjectRoot, assertSafePath } from '../lib/paths.js';
9
+ import { resolveTokenOrLogin } from '../lib/auth.js';
10
+ import { writeToken, readStoredToken } from '../lib/config.js';
11
+ import { downloadAndExtractKit, cleanupTempDir } from '../lib/download.js';
12
+
13
+ /**
14
+ * Run the update command.
15
+ *
16
+ * @param {{
17
+ * sourceDir?: string,
18
+ * targetDir?: string,
19
+ * manifestPath?: string,
20
+ * force?: boolean
21
+ * }} params
22
+ * @returns {Promise<{ updated: string[], added: string[], removed: string[], conflicts: string[], unchanged: string[], upToDate: boolean }>}
23
+ */
24
+ export async function runUpdate(params = {}) {
25
+ const {
26
+ sourceDir = resolveSourceDir(),
27
+ targetDir = resolveTargetDir({ global: false }),
28
+ manifestPath = resolveManifestPath({ global: false }),
29
+ force = false
30
+ } = params;
31
+
32
+ // Read existing manifest
33
+ let manifest;
34
+ try {
35
+ manifest = readManifest(manifestPath);
36
+ } catch (err) {
37
+ throw new Error(`Not installed (no manifest found). Run 'mk init' first. (${err.message})`);
38
+ }
39
+
40
+ // Derive project root from manifest scope (global: homedir, project: dirname(manifestPath))
41
+ const projectRoot = deriveProjectRoot(manifest, manifestPath);
42
+ const claudeRoot = resolve(join(projectRoot, '.claude'));
43
+ const sourceFileList = copyKitFiles(sourceDir, targetDir, { dryRun: true });
44
+ const sourceFiles = {};
45
+ for (const entry of sourceFileList) {
46
+ const checksum = computeChecksum(entry.sourceAbsPath);
47
+ sourceFiles[entry.relativePath] = { checksum, size: entry.size };
48
+ }
49
+
50
+ // Get disk checksums for files currently in manifest
51
+ const diskChecksums = {};
52
+ for (const relPath of Object.keys(manifest.files)) {
53
+ // relPath is like '.claude/agents/foo.md' — relative to project root
54
+ const absPath = join(projectRoot, relPath);
55
+ // Bounds check: manifest keys must not escape .claude/ subtree
56
+ try {
57
+ assertSafePath(absPath, claudeRoot, `manifest key "${relPath}"`);
58
+ } catch {
59
+ process.stderr.write(`Warning: Skipping unsafe path in manifest: ${relPath}\n`);
60
+ continue;
61
+ }
62
+ if (existsSync(absPath)) {
63
+ diskChecksums[relPath] = computeChecksum(absPath);
64
+ }
65
+ }
66
+
67
+ // Three-way diff
68
+ const diff = diffManifest(manifest, sourceFiles, diskChecksums);
69
+
70
+ const upToDate =
71
+ diff.updated.length === 0 &&
72
+ diff.added.length === 0 &&
73
+ diff.removed.length === 0 &&
74
+ diff.conflicts.length === 0;
75
+
76
+ if (upToDate) {
77
+ return { ...diff, upToDate: true };
78
+ }
79
+
80
+ // Read package version (fileURLToPath handles Windows drive-letter prefix correctly)
81
+ const pkg = JSON.parse(readFileSync(fileURLToPath(new URL('../../package.json', import.meta.url)), 'utf8'));
82
+
83
+ const newFiles = { ...manifest.files };
84
+
85
+ /**
86
+ * Copy a source file entry to its destination.
87
+ * @param {string} relPath
88
+ */
89
+ function applyCopy(relPath) {
90
+ const entry = sourceFileList.find(f => f.relativePath === relPath);
91
+ if (!entry) return;
92
+ const destAbs = join(projectRoot, relPath);
93
+ // Bounds check: destination must stay inside .claude/ subtree
94
+ assertSafePath(destAbs, claudeRoot, `copy destination for "${relPath}"`);
95
+ mkdirSync(dirname(destAbs), { recursive: true });
96
+ copyFileSync(entry.sourceAbsPath, destAbs);
97
+ // Reuse source checksum — copy is bit-for-bit identical, no need to re-hash
98
+ newFiles[relPath] = { checksum: sourceFiles[relPath].checksum, size: entry.size };
99
+ }
100
+
101
+ // Apply safe updates
102
+ for (const relPath of diff.updated) {
103
+ applyCopy(relPath);
104
+ }
105
+
106
+ // Handle conflicts
107
+ const skippedConflicts = [];
108
+ for (const relPath of diff.conflicts) {
109
+ if (force) {
110
+ applyCopy(relPath);
111
+ } else {
112
+ skippedConflicts.push(relPath);
113
+ }
114
+ }
115
+
116
+ // Add new files
117
+ for (const relPath of diff.added) {
118
+ applyCopy(relPath);
119
+ }
120
+
121
+ // Remove deleted kit files
122
+ for (const relPath of diff.removed) {
123
+ const absPath = join(projectRoot, relPath);
124
+ // Bounds check: removal must stay inside .claude/ subtree
125
+ try {
126
+ assertSafePath(absPath, claudeRoot, `removal target "${relPath}"`);
127
+ } catch (err) {
128
+ process.stderr.write(`Warning: Skipping unsafe removal path: ${err.message}\n`);
129
+ continue;
130
+ }
131
+ try {
132
+ unlinkSync(absPath);
133
+ } catch {
134
+ // Already missing — skip
135
+ }
136
+ delete newFiles[relPath];
137
+ }
138
+
139
+ // Update manifest with new file map
140
+ updateManifest(manifestPath, newFiles, pkg.version);
141
+
142
+ return {
143
+ updated: diff.updated,
144
+ added: diff.added,
145
+ removed: diff.removed,
146
+ conflicts: skippedConflicts,
147
+ unchanged: diff.unchanged,
148
+ upToDate: false
149
+ };
150
+ }
151
+
152
+ /**
153
+ * CLI action handler for 'mk update'.
154
+ * Downloads fresh kit from GitHub and applies three-way diff.
155
+ *
156
+ * @param {{ force: boolean, global: boolean }} options
157
+ * @param {object} [deps] - Injected dependencies (for testing)
158
+ */
159
+ export async function updateAction(options = {}, deps = {}) {
160
+ const {
161
+ resolveTokenOrLogin: resolveAndLogin = resolveTokenOrLogin,
162
+ downloadAndExtractKit: download = downloadAndExtractKit,
163
+ cleanupTempDir: cleanup = cleanupTempDir,
164
+ writeToken: storeToken = writeToken,
165
+ readStoredToken: readToken = readStoredToken
166
+ } = deps;
167
+
168
+ const targetDir = resolveTargetDir(options);
169
+ const manifestPath = resolveManifestPath(options);
170
+
171
+ let tempDir = null;
172
+ try {
173
+ process.stdout.write('Authenticating with GitHub...\n');
174
+ const token = await resolveAndLogin({
175
+ readStored: readToken,
176
+ writeStored: storeToken,
177
+ display: ({ userCode, verificationUri, expiresIn }) => {
178
+ process.stdout.write('\n');
179
+ process.stdout.write(chalk.bold('To authenticate, visit: ') + chalk.cyan(verificationUri) + '\n');
180
+ process.stdout.write(chalk.bold('And enter the code: ') + chalk.yellow(userCode) + '\n');
181
+ process.stdout.write(`(Code expires in ${Math.round(expiresIn / 60)} minutes)\n\n`);
182
+ process.stdout.write('Waiting for authorization...\n');
183
+ }
184
+ });
185
+
186
+ process.stdout.write('Downloading latest kit from GitHub...\n');
187
+ tempDir = await download(token);
188
+ const sourceDir = join(tempDir, '.claude');
189
+
190
+ const result = await runUpdate({ sourceDir, targetDir, manifestPath, force: options.force });
191
+
192
+ if (result.upToDate) {
193
+ process.stdout.write(chalk.green('Already up to date.\n'));
194
+ } else {
195
+ if (result.updated.length > 0) {
196
+ process.stdout.write(chalk.green(`Updated: ${result.updated.length} files\n`));
197
+ }
198
+ if (result.added.length > 0) {
199
+ process.stdout.write(chalk.green(`Added: ${result.added.length} new files\n`));
200
+ }
201
+ if (result.removed.length > 0) {
202
+ process.stdout.write(chalk.yellow(`Removed: ${result.removed.length} files\n`));
203
+ }
204
+ if (result.conflicts.length > 0) {
205
+ process.stdout.write(
206
+ chalk.yellow(`Skipped: ${result.conflicts.length} user-modified files (use --force to overwrite)\n`)
207
+ );
208
+ for (const f of result.conflicts) {
209
+ process.stdout.write(` ${f}\n`);
210
+ }
211
+ }
212
+ }
213
+ } catch (err) {
214
+ process.stderr.write(chalk.red(`Error: ${err.message}\n`));
215
+ process.exit(1);
216
+ } finally {
217
+ if (tempDir) {
218
+ cleanup(tempDir);
219
+ }
220
+ }
221
+ }
@@ -0,0 +1,211 @@
1
+ import { readStoredToken, writeToken } from './config.js';
2
+ import { GITHUB_API, KIT_REPO } from './constants.js';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Constants
6
+ // ---------------------------------------------------------------------------
7
+
8
+ export const GITHUB_CLIENT_ID = 'Ov23li35aA2A1xVa01B6';
9
+ export const GITHUB_DEVICE_CODE_URL = 'https://github.com/login/device/code';
10
+ export const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token';
11
+
12
+ // Default sleep helper (real delay in production, overridable in tests)
13
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Token resolution
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /**
20
+ * Resolve the current token from env vars or stored file.
21
+ * Priority: GITHUB_TOKEN > GH_TOKEN > stored file > null
22
+ *
23
+ * @param {{ readStored?: () => string|null }} [opts]
24
+ * @returns {string|null}
25
+ */
26
+ export function resolveToken(opts = {}) {
27
+ const readStored = opts.readStored ?? readStoredToken;
28
+
29
+ if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN;
30
+ if (process.env.GH_TOKEN) return process.env.GH_TOKEN;
31
+ return readStored() ?? null;
32
+ }
33
+
34
+ /**
35
+ * Identify the source of a token value.
36
+ *
37
+ * @param {string} token
38
+ * @param {{ readStored?: () => string|null }} [opts]
39
+ * @returns {'env:GITHUB_TOKEN' | 'env:GH_TOKEN' | 'stored' | 'unknown'}
40
+ */
41
+ export function tokenSource(token, opts = {}) {
42
+ const readStored = opts.readStored ?? readStoredToken;
43
+
44
+ if (process.env.GITHUB_TOKEN === token) return 'env:GITHUB_TOKEN';
45
+ if (process.env.GH_TOKEN === token) return 'env:GH_TOKEN';
46
+ if (readStored() === token) return 'stored';
47
+ return 'unknown';
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Token validation
52
+ // ---------------------------------------------------------------------------
53
+
54
+ /**
55
+ * Validate a token by calling the GitHub /user endpoint.
56
+ *
57
+ * @param {string} token
58
+ * @returns {Promise<{ valid: boolean, username?: string }>}
59
+ */
60
+ export async function validateToken(token) {
61
+ try {
62
+ const res = await fetch(`${GITHUB_API}/user`, {
63
+ headers: {
64
+ Authorization: `Bearer ${token}`,
65
+ Accept: 'application/vnd.github.v3+json'
66
+ }
67
+ });
68
+ if (res.ok) {
69
+ const data = await res.json();
70
+ return { valid: true, username: data.login };
71
+ }
72
+ return { valid: false };
73
+ } catch {
74
+ return { valid: false };
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Check whether the token has access to the kit repository.
80
+ *
81
+ * @param {string} token
82
+ * @returns {Promise<{ accessible: boolean }>}
83
+ */
84
+ export async function checkRepoAccess(token) {
85
+ try {
86
+ const res = await fetch(`${GITHUB_API}/repos/${KIT_REPO}`, {
87
+ headers: {
88
+ Authorization: `Bearer ${token}`,
89
+ Accept: 'application/vnd.github.v3+json'
90
+ }
91
+ });
92
+ return { accessible: res.ok };
93
+ } catch {
94
+ return { accessible: false };
95
+ }
96
+ }
97
+
98
+ // ---------------------------------------------------------------------------
99
+ // OAuth Device Flow
100
+ // ---------------------------------------------------------------------------
101
+
102
+ /**
103
+ * Run the GitHub OAuth Device Flow and return the access token.
104
+ *
105
+ * @param {{
106
+ * display: (info: { userCode: string, verificationUri: string, expiresIn: number }) => void,
107
+ * writeStored?: (token: string) => void,
108
+ * sleep?: (ms: number) => Promise<void>
109
+ * }} opts
110
+ * @returns {Promise<string>} Access token
111
+ */
112
+ export async function startDeviceFlow(opts = {}) {
113
+ const {
114
+ display,
115
+ writeStored = writeToken,
116
+ sleep = defaultSleep
117
+ } = opts;
118
+
119
+ // Step 1: Request device + user code
120
+ const codeRes = await fetch(GITHUB_DEVICE_CODE_URL, {
121
+ method: 'POST',
122
+ headers: {
123
+ 'Content-Type': 'application/x-www-form-urlencoded',
124
+ Accept: 'application/json'
125
+ },
126
+ body: `client_id=${GITHUB_CLIENT_ID}&scope=repo`
127
+ });
128
+
129
+ if (!codeRes.ok) {
130
+ throw new Error(`Device flow failed: ${codeRes.status} ${codeRes.statusText}`);
131
+ }
132
+
133
+ const codeData = await codeRes.json();
134
+ const { device_code, user_code, verification_uri, interval = 5, expires_in = 900 } = codeData;
135
+
136
+ // Display instructions to the user
137
+ display({ userCode: user_code, verificationUri: verification_uri, expiresIn: expires_in });
138
+
139
+ // Step 2: Poll for the access token
140
+ const deadline = Date.now() + expires_in * 1000;
141
+ let pollInterval = interval * 1000;
142
+
143
+ while (Date.now() < deadline) {
144
+ await sleep(pollInterval);
145
+
146
+ const tokenRes = await fetch(GITHUB_TOKEN_URL, {
147
+ method: 'POST',
148
+ headers: {
149
+ 'Content-Type': 'application/x-www-form-urlencoded',
150
+ Accept: 'application/json'
151
+ },
152
+ body: `client_id=${GITHUB_CLIENT_ID}&device_code=${device_code}&grant_type=urn:ietf:params:oauth:grant-type:device_code`
153
+ });
154
+
155
+ const tokenData = await tokenRes.json();
156
+
157
+ if (tokenData.access_token) {
158
+ const token = tokenData.access_token;
159
+ writeStored(token);
160
+ return token;
161
+ }
162
+
163
+ switch (tokenData.error) {
164
+ case 'authorization_pending':
165
+ // User hasn't authorized yet — keep polling
166
+ break;
167
+ case 'slow_down':
168
+ // GitHub asks us to slow down
169
+ pollInterval += 5000;
170
+ break;
171
+ case 'expired_token':
172
+ throw new Error('Authentication timed out. Please try \'mk auth login\' again.');
173
+ case 'access_denied':
174
+ throw new Error('Access denied by user. Authentication cancelled.');
175
+ default:
176
+ throw new Error(`Device flow error: ${tokenData.error}`);
177
+ }
178
+ }
179
+
180
+ throw new Error('Authentication timed out. Please try \'mk auth login\' again.');
181
+ }
182
+
183
+ // ---------------------------------------------------------------------------
184
+ // Combined: resolve or trigger login
185
+ // ---------------------------------------------------------------------------
186
+
187
+ /**
188
+ * Return an existing token, or run the device flow if none is available.
189
+ *
190
+ * @param {{
191
+ * readStored?: () => string|null,
192
+ * writeStored?: (token: string) => void,
193
+ * display: (info: object) => void,
194
+ * sleep?: (ms: number) => Promise<void>
195
+ * }} opts
196
+ * @returns {Promise<string>}
197
+ */
198
+ export async function resolveTokenOrLogin(opts = {}) {
199
+ const {
200
+ readStored = readStoredToken,
201
+ writeStored = writeToken,
202
+ display,
203
+ sleep = defaultSleep
204
+ } = opts;
205
+
206
+ const existing = resolveToken({ readStored });
207
+ if (existing) return existing;
208
+
209
+ // No token — run device flow
210
+ return startDeviceFlow({ display, writeStored, sleep });
211
+ }
@@ -0,0 +1,13 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+
4
+ /**
5
+ * Compute SHA-256 checksum of a file.
6
+ * @param {string} filePath - Absolute path to file
7
+ * @returns {string} Checksum string prefixed with 'sha256:'
8
+ */
9
+ export function computeChecksum(filePath) {
10
+ const content = readFileSync(filePath);
11
+ const hash = createHash('sha256').update(content).digest('hex');
12
+ return `sha256:${hash}`;
13
+ }
@@ -0,0 +1,72 @@
1
+ import { join } from 'node:path';
2
+ import { homedir } from 'node:os';
3
+ import { mkdirSync, writeFileSync, readFileSync, unlinkSync, chmodSync, existsSync } from 'node:fs';
4
+
5
+ /**
6
+ * Returns the config directory for mk.
7
+ * XDG-compliant: ~/.config/mk on Unix/macOS, %APPDATA%/mk on Windows.
8
+ * @returns {string}
9
+ */
10
+ export function getConfigDir() {
11
+ const appData = process.env.APPDATA;
12
+ // Reject UNC paths (\\server\share) — these could redirect token storage to attacker-controlled
13
+ // network shares. Fall back to the XDG path if APPDATA looks suspicious.
14
+ if (appData && !appData.startsWith('\\\\')) {
15
+ return join(appData, 'mk');
16
+ }
17
+ return join(homedir(), '.config', 'mk');
18
+ }
19
+
20
+ /**
21
+ * Returns the token file path.
22
+ * @returns {string}
23
+ */
24
+ export function getTokenPath() {
25
+ return join(getConfigDir(), 'token');
26
+ }
27
+
28
+ /**
29
+ * Read the stored token from disk.
30
+ * @returns {string|null} Token string or null if not found.
31
+ */
32
+ export function readStoredToken() {
33
+ const tokenPath = getTokenPath();
34
+ if (!existsSync(tokenPath)) {
35
+ return null;
36
+ }
37
+ try {
38
+ return readFileSync(tokenPath, 'utf8').trim();
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Write the token to disk. Creates config dir if needed. Sets chmod 600 on Unix.
46
+ * @param {string} token
47
+ */
48
+ export function writeToken(token) {
49
+ const configDir = getConfigDir();
50
+ const tokenPath = getTokenPath();
51
+ mkdirSync(configDir, { recursive: true });
52
+ // Write with mode 0o600 atomically — prevents TOCTOU window between write and chmod.
53
+ // On Windows the mode flag is ignored; home-dir ACLs provide equivalent protection.
54
+ writeFileSync(tokenPath, token, { encoding: 'utf8', mode: 0o600 });
55
+ if (process.platform !== 'win32') {
56
+ // Explicit chmod ensures mode is set even if umask overrides the O_CREAT mode.
57
+ chmodSync(tokenPath, 0o600);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Delete the stored token file.
63
+ * Does not throw if the file does not exist.
64
+ */
65
+ export function deleteToken() {
66
+ const tokenPath = getTokenPath();
67
+ try {
68
+ unlinkSync(tokenPath);
69
+ } catch {
70
+ // File doesn't exist — that's fine
71
+ }
72
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Kit subdirectories to copy/manage (relative to .claude/)
3
+ */
4
+ export const KIT_SUBDIRS = ['agents', 'skills', 'workflows'];
5
+
6
+ /**
7
+ * Manifest file name
8
+ */
9
+ export const MANIFEST_FILENAME = '.mk-manifest.json';
10
+
11
+ /**
12
+ * Files/patterns to exclude during copy
13
+ */
14
+ export const COPY_FILTER_PATTERNS = [
15
+ '__pycache__',
16
+ '.pyc',
17
+ '.pyo',
18
+ 'node_modules',
19
+ '.DS_Store',
20
+ 'package-lock.json',
21
+ '.env'
22
+ ];
23
+
24
+ /**
25
+ * Windows path length warning threshold (leave 20 chars of buffer)
26
+ */
27
+ export const WINDOWS_PATH_WARN_LENGTH = 240;
28
+
29
+ /**
30
+ * GitHub API base URL
31
+ */
32
+ export const GITHUB_API = 'https://api.github.com';
33
+
34
+ /**
35
+ * Kit repository (owner/repo)
36
+ */
37
+ export const KIT_REPO = 'khanhtran148/mk-kit';