mkctx 3.0.0 → 4.0.1

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 (3) hide show
  1. package/README.md +173 -146
  2. package/bin/mkctx.js +510 -395
  3. package/package.json +1 -1
package/bin/mkctx.js CHANGED
@@ -1,20 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const fs = require('fs');
3
+ 'use strict';
4
+
5
+ const fs = require('fs');
4
6
  const path = require('path');
5
7
 
6
8
  // ============================================
7
- // LAZY LOAD DEPENDENCIES (for faster startup)
9
+ // LAZY-LOADED DEPENDENCIES
8
10
  // ============================================
9
11
 
10
12
  let inquirer, chalk, ora;
11
13
 
12
14
  function loadDependencies() {
13
- if (!inquirer) {
14
- inquirer = require('inquirer');
15
- chalk = require('chalk');
16
- ora = require('ora');
17
- }
15
+ if (inquirer) return;
16
+ inquirer = require('inquirer');
17
+ chalk = require('chalk');
18
+ ora = require('ora');
18
19
  }
19
20
 
20
21
  // ============================================
@@ -23,29 +24,29 @@ function loadDependencies() {
23
24
 
24
25
  const CONFIG_FILE = 'mkctx.config.json';
25
26
 
26
- const DEFAULT_PROJECT_CONFIG = {
27
- src: ".",
28
- ignore: "mkctx.config.json, pnpm-lock.yaml, **/.titan/, mkctx/, node_modules/, .git/, dist/, build/, target/, .next/, out/, .cache, package-lock.json, README.md, *.log, temp/, tmp/, coverage/, .nyc_output, .env, .env.local, .env.development.local, .env.test.local, .env.production.local, npm-debug.log*, yarn-debug.log*, yarn-error.log*, .npm, .yarn-integrity, .parcel-cache, .vuepress/dist, .svelte-kit, **/*.rs.bk, .idea/, .vscode/, .DS_Store, Thumbs.db, *.swp, *.swo, .~lock.*, Cargo.lock, .cargo/registry/, .cargo/git/, .rustup/, *.pdb, *.dSYM/, *.so, *.dll, *.dylib, *.exe, *.lib, *.a, *.o, *.rlib, *.d, *.tmp, *.bak, *.orig, *.rej, *.pyc, *.pyo, *.class, *.jar, *.war, *.ear, *.zip, *.tar.gz, *.rar, *.7z, *.iso, *.img, *.dmg, *.pdf, *.doc, *.docx, *.xls, *.xlsx, *.ppt, *.pptx",
29
- output: "./mkctx",
30
- first_comment: "/* Project Context */",
31
- last_comment: "/* End of Context */"
27
+ const DEFAULT_CONFIG = {
28
+ src: '.',
29
+ ignore: 'mkctx.config.json, pnpm-lock.yaml, **/.titan/, mkctx/, node_modules/, .git/, dist/, build/, target/, .next/, out/, .cache, package-lock.json, *.log, temp/, tmp/, coverage/, .nyc_output, .env, .env.local, .env.development.local, .env.test.local, .env.production.local, npm-debug.log*, yarn-debug.log*, yarn-error.log*, .npm, .yarn-integrity, .parcel-cache, .vuepress/dist, .svelte-kit, **/*.rs.bk, .idea/, .vscode/, .DS_Store, Thumbs.db, *.swp, *.swo, .~lock.*, Cargo.lock, .cargo/registry/, .cargo/git/, .rustup/, *.pdb, *.dSYM/, *.so, *.dll, *.dylib, *.exe, *.lib, *.a, *.o, *.rlib, *.d, *.tmp, *.bak, *.orig, *.rej, *.pyc, *.pyo, *.class, *.jar, *.war, *.ear, *.zip, *.tar.gz, *.rar, *.7z, *.iso, *.img, *.dmg, *.pdf, *.doc, *.docx, *.xls, *.xlsx, *.ppt, *.pptx',
30
+ output: './mkctx',
31
+ first_comment: '/* Project Context */',
32
+ last_comment: '/* End of Context */',
32
33
  };
33
34
 
34
- // Language mapping for syntax highlighting
35
+ const VALID_FORMATS = ['json', 'md', 'toon', 'xml'];
36
+
35
37
  const LANG_MAP = {
36
- js: 'javascript', ts: 'typescript', jsx: 'jsx', tsx: 'tsx',
37
- py: 'python', rb: 'ruby', go: 'go', rs: 'rust',
38
- java: 'java', kt: 'kotlin', cs: 'csharp', cpp: 'cpp',
39
- c: 'c', h: 'c', hpp: 'cpp', php: 'php',
40
- sh: 'bash', bash: 'bash', zsh: 'bash', ps1: 'powershell',
41
- sql: 'sql', html: 'html', css: 'css', scss: 'scss',
42
- sass: 'sass', less: 'less', json: 'json', xml: 'xml',
43
- yaml: 'yaml', yml: 'yaml', md: 'markdown', vue: 'vue',
38
+ js: 'javascript', ts: 'typescript', jsx: 'jsx', tsx: 'tsx',
39
+ py: 'python', rb: 'ruby', go: 'go', rs: 'rust',
40
+ java: 'java', kt: 'kotlin', cs: 'csharp', cpp: 'cpp',
41
+ c: 'c', h: 'c', hpp: 'cpp', php: 'php',
42
+ sh: 'bash', bash: 'bash', zsh: 'bash', ps1: 'powershell',
43
+ sql: 'sql', html: 'html', css: 'css', scss: 'scss',
44
+ sass: 'sass', less: 'less', json: 'json', xml: 'xml',
45
+ yaml: 'yaml', yml: 'yaml', md: 'markdown', vue: 'vue',
44
46
  svelte: 'svelte', dockerfile: 'dockerfile', makefile: 'makefile',
45
- toml: 'toml', ini: 'ini', cfg: 'ini', env: 'bash'
47
+ toml: 'toml', ini: 'ini', cfg: 'ini', env: 'bash',
46
48
  };
47
49
 
48
- // Text file extensions
49
50
  const TEXT_EXTENSIONS = new Set([
50
51
  '.js', '.ts', '.jsx', '.tsx', '.mjs', '.cjs',
51
52
  '.py', '.pyw', '.rb', '.rake', '.go', '.rs',
@@ -65,7 +66,7 @@ const TEXT_EXTENSIONS = new Set([
65
66
  '.ex', '.exs', '.erl', '.hrl', '.clj', '.cljs', '.cljc',
66
67
  '.hs', '.lhs', '.elm', '.pug', '.jade',
67
68
  '.ejs', '.hbs', '.handlebars', '.twig', '.blade.php',
68
- '.astro', '.prisma', '.sol'
69
+ '.astro', '.prisma', '.sol',
69
70
  ]);
70
71
 
71
72
  const KNOWN_FILES = new Set([
@@ -73,376 +74,517 @@ const KNOWN_FILES = new Set([
73
74
  'procfile', 'vagrantfile', 'jenkinsfile',
74
75
  '.gitignore', '.gitattributes', '.editorconfig',
75
76
  '.eslintrc', '.prettierrc', '.babelrc',
76
- '.env', '.env.example', '.env.local'
77
+ '.env', '.env.example', '.env.local',
78
+ 'readme.md', 'readme.txt', 'readme',
79
+ 'license', 'license.md', 'license.txt',
77
80
  ]);
78
81
 
79
- // ============================================
80
- // GLOBAL STATE
81
- // ============================================
82
+ const SYSTEM_IGNORES = [
83
+ '.git', '.DS_Store', 'Thumbs.db', 'node_modules',
84
+ '.svn', '.hg', '__pycache__', '.pytest_cache',
85
+ '.mypy_cache', '.vscode', '.idea',
86
+ ];
82
87
 
83
- let generatedContext = null;
84
- let contextFiles = [];
85
- let contextStats = {};
88
+ // Flags that take a value (not boolean)
89
+ const VALUE_FLAGS = new Set([
90
+ 'src', 'output', 'format', 'ignore', 'name', 'first-comment', 'last-comment',
91
+ 's', 'o', 'f', 'n',
92
+ ]);
93
+
94
+ // Flags that trigger non-interactive mode
95
+ const NON_INTERACTIVE_FLAGS = new Set([
96
+ 'src', 's', 'output', 'o', 'format', 'f',
97
+ 'ignore', 'name', 'n', 'first-comment', 'last-comment',
98
+ ]);
86
99
 
87
100
  // ============================================
88
- // CONFIGURATION MANAGEMENT
101
+ // CLI ARGUMENT PARSING
89
102
  // ============================================
90
103
 
91
- function loadProjectConfig() {
92
- if (fs.existsSync(CONFIG_FILE)) {
93
- try {
94
- const config = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
95
- return { ...DEFAULT_PROJECT_CONFIG, ...config };
96
- } catch (err) {
97
- console.log(chalk.yellow('⚠️ Error parsing config file, using defaults'));
98
- return { ...DEFAULT_PROJECT_CONFIG };
104
+ function parseArgs(argv) {
105
+ const args = argv.slice(2);
106
+ const flags = {};
107
+ let command = null;
108
+
109
+ if (args.length > 0 && !args[0].startsWith('-')) {
110
+ command = args[0];
111
+ }
112
+
113
+ for (let i = 0; i < args.length; i++) {
114
+ const arg = args[i];
115
+
116
+ if (arg.startsWith('--') && arg.includes('=')) {
117
+ const eq = arg.indexOf('=');
118
+ flags[arg.slice(2, eq)] = arg.slice(eq + 1);
119
+ continue;
120
+ }
121
+
122
+ if (arg.startsWith('-') && !arg.startsWith('--') && arg.includes('=')) {
123
+ const eq = arg.indexOf('=');
124
+ flags[arg.slice(1, eq)] = arg.slice(eq + 1);
125
+ continue;
126
+ }
127
+
128
+ if (arg.startsWith('--')) {
129
+ const key = arg.slice(2);
130
+ const next = args[i + 1];
131
+ if (VALUE_FLAGS.has(key) && next !== undefined && !isFlagToken(next)) {
132
+ flags[key] = next;
133
+ i++;
134
+ } else {
135
+ flags[key] = true;
136
+ }
137
+ continue;
138
+ }
139
+
140
+ if (arg.startsWith('-') && arg.length === 2) {
141
+ const key = arg.slice(1);
142
+ const next = args[i + 1];
143
+ if (VALUE_FLAGS.has(key) && next !== undefined && !isFlagToken(next)) {
144
+ flags[key] = next;
145
+ i++;
146
+ } else {
147
+ flags[key] = true;
148
+ }
99
149
  }
100
150
  }
101
- return null;
151
+
152
+ return { command, flags };
153
+ }
154
+
155
+ function isFlagToken(str) {
156
+ return /^--[a-zA-Z]/.test(str) || /^-[a-zA-Z]/.test(str);
157
+ }
158
+
159
+ function isNonInteractiveMode(flags) {
160
+ return Object.keys(flags).some(f => NON_INTERACTIVE_FLAGS.has(f));
161
+ }
162
+
163
+ // Resolve short aliases to canonical names
164
+ function resolveFlags(flags) {
165
+ return {
166
+ src: flags.src || flags.s,
167
+ output: flags.output || flags.o,
168
+ format: flags.format || flags.f,
169
+ name: flags.name || flags.n,
170
+ ignore: flags.ignore,
171
+ firstComment: flags['first-comment'],
172
+ lastComment: flags['last-comment'],
173
+ };
102
174
  }
103
175
 
104
- function hasProjectConfig() {
176
+ // ============================================
177
+ // CONFIG MANAGEMENT
178
+ // ============================================
179
+
180
+ function configFileExists() {
105
181
  return fs.existsSync(CONFIG_FILE);
106
182
  }
107
183
 
108
- function createProjectConfig() {
109
- loadDependencies();
110
-
111
- if (!fs.existsSync('mkctx')) {
112
- fs.mkdirSync('mkctx', { recursive: true });
184
+ function loadConfig() {
185
+ if (!configFileExists()) return null;
186
+ try {
187
+ const raw = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8'));
188
+ return { ...DEFAULT_CONFIG, ...raw };
189
+ } catch {
190
+ console.log(chalk.yellow('⚠️ Error parsing config file, using defaults'));
191
+ return { ...DEFAULT_CONFIG };
113
192
  }
193
+ }
114
194
 
115
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(DEFAULT_PROJECT_CONFIG, null, 2));
116
- updateGitignore();
195
+ function buildConfig(cliFlags = {}) {
196
+ const base = configFileExists() ? loadConfig() : { ...DEFAULT_CONFIG };
197
+ if (cliFlags.src) base.src = cliFlags.src;
198
+ if (cliFlags.output) base.output = cliFlags.output;
199
+ if (cliFlags.ignore) base.ignore = cliFlags.ignore;
200
+ if (cliFlags.firstComment) base.first_comment = cliFlags.firstComment;
201
+ if (cliFlags.lastComment) base.last_comment = cliFlags.lastComment;
202
+ return base;
203
+ }
117
204
 
205
+ function createConfigFile() {
206
+ loadDependencies();
207
+ fs.mkdirSync('mkctx', { recursive: true });
208
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(DEFAULT_CONFIG, null, 2));
209
+ appendToGitignore();
118
210
  console.log(chalk.green('\n✅ Configuration created:'));
119
211
  console.log(chalk.white(' - mkctx.config.json'));
120
212
  console.log(chalk.white(' - mkctx/ folder'));
121
213
  console.log(chalk.white(' - Entry in .gitignore\n'));
122
214
  }
123
215
 
124
- function updateGitignore() {
216
+ function appendToGitignore() {
125
217
  const gitignorePath = '.gitignore';
126
- let content = '';
127
-
128
- if (fs.existsSync(gitignorePath)) {
129
- content = fs.readFileSync(gitignorePath, 'utf-8');
218
+ const current = fs.existsSync(gitignorePath)
219
+ ? fs.readFileSync(gitignorePath, 'utf-8')
220
+ : '';
221
+ if (!current.includes('mkctx/')) {
222
+ fs.writeFileSync(gitignorePath, current + '\n# mkctx - generated context\nmkctx/\n');
130
223
  }
224
+ }
225
+
226
+ // ============================================
227
+ // FORMAT RESOLUTION
228
+ // ============================================
229
+
230
+ function resolveFormats(formatArg) {
231
+ if (!formatArg) return ['md'];
232
+ if (formatArg === 'all') return [...VALID_FORMATS];
233
+
234
+ const requested = formatArg.split(',').map(f => f.trim().toLowerCase());
235
+ const invalid = requested.filter(f => !VALID_FORMATS.includes(f));
131
236
 
132
- if (!content.includes('mkctx/')) {
133
- const entry = '\n# mkctx - generated context\nmkctx/\n';
134
- content += entry;
135
- fs.writeFileSync(gitignorePath, content);
237
+ if (invalid.length > 0) {
238
+ console.error(`❌ Invalid format(s): ${invalid.join(', ')}. Valid: ${VALID_FORMATS.join(', ')}, all`);
239
+ process.exit(1);
136
240
  }
241
+
242
+ return requested;
137
243
  }
138
244
 
139
245
  // ============================================
140
- // UTILITY FUNCTIONS
246
+ // UTILITY HELPERS
141
247
  // ============================================
142
248
 
143
249
  function formatSize(bytes) {
144
- const sizes = ['B', 'KB', 'MB', 'GB'];
250
+ const units = ['B', 'KB', 'MB', 'GB'];
145
251
  if (bytes === 0) return '0 B';
146
252
  const i = Math.floor(Math.log(bytes) / Math.log(1024));
147
- return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${sizes[i]}`;
253
+ return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
148
254
  }
149
255
 
150
256
  function estimateTokens(text) {
151
257
  return Math.ceil(text.length / 4);
152
258
  }
153
259
 
260
+ function toUnixPath(filePath) {
261
+ return filePath.replace(/\\/g, '/');
262
+ }
263
+
154
264
  // ============================================
155
- // FILE OPERATIONS
265
+ // FILE FILTERING
156
266
  // ============================================
157
267
 
158
268
  function isTextFile(filename) {
159
- const ext = path.extname(filename).toLowerCase();
269
+ const ext = path.extname(filename).toLowerCase();
160
270
  const basename = path.basename(filename).toLowerCase();
161
-
162
- if (KNOWN_FILES.has(basename)) return true;
163
- if (ext && TEXT_EXTENSIONS.has(ext)) return true;
164
-
165
- return false;
271
+ return KNOWN_FILES.has(basename) || (!!ext && TEXT_EXTENSIONS.has(ext));
166
272
  }
167
273
 
168
274
  function getLanguage(filename) {
169
- const ext = path.extname(filename).slice(1).toLowerCase();
275
+ const ext = path.extname(filename).slice(1).toLowerCase();
170
276
  const basename = path.basename(filename).toLowerCase();
171
-
172
- if (basename === 'dockerfile') return 'dockerfile';
173
- if (basename === 'makefile') return 'makefile';
174
- if (basename.startsWith('.env')) return 'bash';
175
-
277
+ if (basename === 'dockerfile') return 'dockerfile';
278
+ if (basename === 'makefile') return 'makefile';
279
+ if (basename.startsWith('.env')) return 'bash';
176
280
  return LANG_MAP[ext] || ext || 'text';
177
281
  }
178
282
 
179
283
  function parseIgnorePatterns(ignoreString) {
180
284
  if (!ignoreString) return [];
181
- return ignoreString
182
- .split(',')
183
- .map(p => p.trim())
184
- .filter(Boolean);
285
+ return ignoreString.split(',').map(p => p.trim()).filter(Boolean);
185
286
  }
186
287
 
187
- function matchWildcard(pattern, filename) {
188
- const regexPattern = pattern
189
- .replace(/\./g, '\\.')
190
- .replace(/\*\*/g, '.*')
191
- .replace(/\*/g, '[^/]*');
192
- const regex = new RegExp(`^${regexPattern}$`, 'i');
193
- return regex.test(filename);
288
+ function matchWildcard(pattern, subject) {
289
+ const regex = new RegExp(
290
+ '^' + pattern
291
+ .replace(/\./g, '\\.')
292
+ .replace(/\*\*/g, '.*')
293
+ .replace(/\*/g, '[^/]*') + '$',
294
+ 'i'
295
+ );
296
+ return regex.test(subject);
194
297
  }
195
298
 
196
299
  function shouldIgnore(fullPath, name, relativePath, patterns) {
197
- const systemIgnores = [
198
- '.git', '.DS_Store', 'Thumbs.db', 'node_modules',
199
- '.svn', '.hg', '__pycache__', '.pytest_cache',
200
- '.mypy_cache', '.vscode', '.idea'
201
- ];
202
-
203
- for (const ignore of systemIgnores) {
204
- if (fullPath.includes(path.sep + ignore + path.sep) ||
205
- fullPath.includes(ignore + path.sep) ||
206
- name === ignore) {
207
- return true;
208
- }
209
- }
300
+ const normFull = toUnixPath(fullPath);
301
+ const normRelative = toUnixPath(relativePath);
302
+
303
+ const inSystemIgnore = SYSTEM_IGNORES.some(ig =>
304
+ normFull.includes(`/${ig}/`) ||
305
+ normFull.includes(`/${ig}`) ||
306
+ normFull.endsWith(`/${ig}`) ||
307
+ name === ig
308
+ );
309
+ if (inSystemIgnore) return true;
210
310
 
211
- for (const pattern of patterns) {
311
+ return patterns.some(pattern => {
212
312
  if (pattern.includes('*')) {
213
- if (matchWildcard(pattern, name)) return true;
214
- if (matchWildcard(pattern, relativePath)) return true;
313
+ return matchWildcard(pattern, name) || matchWildcard(pattern, normRelative);
215
314
  }
216
-
217
315
  if (pattern.endsWith('/')) {
218
316
  const dir = pattern.slice(0, -1);
219
- if (fullPath.includes(path.sep + dir + path.sep) ||
220
- fullPath.includes(dir + path.sep) ||
221
- name === dir) {
222
- return true;
223
- }
224
- }
225
-
226
- if (relativePath === pattern || name === pattern) {
227
- return true;
317
+ return normFull.includes(`/${dir}/`) ||
318
+ normFull.endsWith(`/${dir}`) ||
319
+ name === dir;
228
320
  }
229
- }
230
-
231
- return false;
321
+ return normRelative === pattern || name === pattern;
322
+ });
232
323
  }
233
324
 
234
- function getFiles(srcPath, config) {
235
- const files = [];
325
+ // ============================================
326
+ // FILE SCANNING
327
+ // ============================================
328
+
329
+ function scanFiles(srcPath, config) {
236
330
  const ignorePatterns = parseIgnorePatterns(config.ignore);
331
+ const files = [];
332
+ const stats = { files: 0, totalSize: 0, totalLines: 0, filesByExt: {} };
237
333
 
238
334
  function walk(dir) {
239
335
  if (!fs.existsSync(dir)) return;
240
336
 
241
337
  let entries;
242
- try {
243
- entries = fs.readdirSync(dir, { withFileTypes: true });
244
- } catch (err) {
245
- return;
246
- }
338
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); }
339
+ catch { return; }
247
340
 
248
341
  for (const entry of entries) {
249
- const fullPath = path.join(dir, entry.name);
342
+ const fullPath = path.join(dir, entry.name);
250
343
  const relativePath = path.relative(srcPath, fullPath);
251
344
 
252
- if (shouldIgnore(fullPath, entry.name, relativePath, ignorePatterns)) {
253
- continue;
254
- }
255
-
256
- if (entry.isDirectory()) {
257
- walk(fullPath);
258
- } else if (entry.isFile() && isTextFile(entry.name)) {
259
- files.push({
260
- fullPath,
261
- relativePath,
262
- name: entry.name,
263
- ext: path.extname(entry.name).slice(1).toLowerCase()
264
- });
265
- }
266
- }
267
- }
268
-
269
- walk(srcPath);
270
- return files.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
271
- }
272
-
273
- function buildContextContent(files, config, srcPath) {
274
- let content = '';
275
- let totalSize = 0;
276
- let totalLines = 0;
277
- const filesByExt = {};
278
-
279
- if (config.first_comment) {
280
- content += config.first_comment + '\n\n';
281
- }
282
-
283
- content += '## Project Structure\n\n```\n';
284
- const dirs = new Set();
285
- files.forEach(f => {
286
- const dir = path.dirname(f.relativePath);
287
- if (dir !== '.') dirs.add(dir);
288
- });
289
- dirs.forEach(d => content += `📁 ${d}/\n`);
290
- content += `\n${files.length} files total\n\`\`\`\n\n`;
291
-
292
- content += '## Source Files\n\n';
345
+ if (shouldIgnore(fullPath, entry.name, relativePath, ignorePatterns)) continue;
293
346
 
294
- for (const file of files) {
295
- let fileContent;
296
- try {
297
- fileContent = fs.readFileSync(file.fullPath, 'utf-8');
298
- } catch (err) {
299
- console.log(chalk.yellow(`⚠️ Could not read: ${file.relativePath}`));
300
- continue;
301
- }
347
+ if (entry.isDirectory()) { walk(fullPath); continue; }
348
+ if (!entry.isFile() || !isTextFile(entry.name)) continue;
302
349
 
303
- const lang = getLanguage(file.name);
304
- const lines = fileContent.split('\n').length;
350
+ let content;
351
+ try { content = fs.readFileSync(fullPath, 'utf-8'); }
352
+ catch { continue; }
305
353
 
306
- totalSize += Buffer.byteLength(fileContent, 'utf-8');
307
- totalLines += lines;
308
- filesByExt[file.ext || 'other'] = (filesByExt[file.ext || 'other'] || 0) + 1;
354
+ const ext = path.extname(entry.name).slice(1).toLowerCase() || null;
355
+ const lines = content.split('\n').length;
356
+ const size = Buffer.byteLength(content, 'utf-8');
357
+ const language = getLanguage(entry.name);
309
358
 
310
- content += `### ${file.relativePath}\n\n`;
311
- content += '```' + lang + '\n';
312
- content += fileContent;
359
+ stats.totalSize += size;
360
+ stats.totalLines += lines;
361
+ stats.filesByExt[ext || 'other'] = (stats.filesByExt[ext || 'other'] || 0) + 1;
313
362
 
314
- if (!fileContent.endsWith('\n')) {
315
- content += '\n';
363
+ files.push({ path: toUnixPath(relativePath), name: entry.name, extension: ext, language, lines, size, content });
316
364
  }
317
-
318
- content += '```\n\n';
319
365
  }
320
366
 
321
- if (config.last_comment) {
322
- content += config.last_comment;
323
- }
324
-
325
- contextStats = {
326
- files: files.length,
327
- totalSize,
328
- totalLines,
329
- filesByExt,
330
- estimatedTokens: estimateTokens(content)
331
- };
367
+ walk(srcPath);
368
+ files.sort((a, b) => a.path.localeCompare(b.path));
369
+ stats.files = files.length;
332
370
 
333
- return content;
371
+ return { files, stats };
334
372
  }
335
373
 
336
374
  // ============================================
337
- // CONTEXT GENERATION
375
+ // OUTPUT FORMATTERS
338
376
  // ============================================
339
377
 
340
- async function generateContextDynamic() {
341
- loadDependencies();
378
+ function toJson(files) {
379
+ return JSON.stringify(files, null, 2);
380
+ }
342
381
 
343
- const { srcPath } = await inquirer.prompt([
344
- {
345
- type: 'input',
346
- name: 'srcPath',
347
- message: 'Enter the source path to analyze:',
348
- default: '.',
349
- validate: (input) => {
350
- if (!fs.existsSync(input)) {
351
- return `Path does not exist: ${input}`;
352
- }
353
- return true;
354
- }
355
- }
356
- ]);
382
+ function toMarkdown(files, config) {
383
+ const dirs = [...new Set(
384
+ files.map(f => path.dirname(f.path)).filter(d => d !== '.')
385
+ )].sort();
386
+
387
+ const structure = dirs.map(d => `📁 ${d}/`).join('\n');
388
+
389
+ const sources = files.map(file => {
390
+ const body = file.content.endsWith('\n') ? file.content : file.content + '\n';
391
+ return `### ${file.path}\n\n\`\`\`${file.language}\n${body}\`\`\`\n`;
392
+ }).join('\n');
393
+
394
+ return [
395
+ config.first_comment,
396
+ '',
397
+ '## Project Structure',
398
+ '',
399
+ '```',
400
+ structure,
401
+ '',
402
+ `${files.length} files total`,
403
+ '```',
404
+ '',
405
+ '## Source Files',
406
+ '',
407
+ sources,
408
+ config.last_comment,
409
+ ].join('\n');
410
+ }
357
411
 
358
- const config = { ...DEFAULT_PROJECT_CONFIG, src: srcPath };
359
- return generateContextFromConfig(config, srcPath);
412
+ function toToon(files, stats) {
413
+ const header = [
414
+ '# Project Context',
415
+ `# Generated: ${new Date().toISOString()}`,
416
+ `# Files: ${files.length}`,
417
+ `# Lines: ${stats.totalLines}`,
418
+ `# Size: ${stats.totalSize} bytes`,
419
+ '',
420
+ `files[${files.length}]{path,name,extension,language,lines,size}:`,
421
+ ...files.map(f =>
422
+ ` ${escapeToon(f.path)},${escapeToon(f.name)},${f.extension || ''},${f.language},${f.lines},${f.size}`
423
+ ),
424
+ '',
425
+ ].join('\n');
426
+
427
+ const bodies = files.map((f, i) => [
428
+ '---',
429
+ `[${i}] ${f.path}`,
430
+ `language: ${f.language}`,
431
+ 'content:',
432
+ ...f.content.split('\n').map(l => ` ${l}`),
433
+ ].join('\n')).join('\n');
434
+
435
+ return header + bodies;
360
436
  }
361
437
 
362
- async function generateContextFromConfigFile() {
363
- loadDependencies();
364
-
365
- const config = loadProjectConfig();
366
- if (!config) {
367
- console.log(chalk.yellow('\n⚠️ No config file found.'));
368
- return null;
369
- }
438
+ function escapeToon(value) {
439
+ if (value == null) return '';
440
+ const str = String(value);
441
+ const needsQuotes = str.includes(',') || str.includes('\n') || str.includes('"')
442
+ || str.startsWith(' ') || str.endsWith(' ');
443
+ return needsQuotes
444
+ ? `"${str.replace(/"/g, '""').replace(/\n/g, '\\n')}"`
445
+ : str;
446
+ }
370
447
 
371
- return generateContextFromConfig(config, config.src);
448
+ function toXml(files) {
449
+ const fileEntries = files.map(f => [
450
+ ' <file>',
451
+ ` <path>${escapeXml(f.path)}</path>`,
452
+ ` <name>${escapeXml(f.name)}</name>`,
453
+ ` <extension>${escapeXml(f.extension || '')}</extension>`,
454
+ ` <language>${escapeXml(f.language)}</language>`,
455
+ ` <lines>${f.lines}</lines>`,
456
+ ` <size>${f.size}</size>`,
457
+ ` <content><![CDATA[\n${f.content}${f.content.endsWith('\n') ? '' : '\n'}]]></content>`,
458
+ ' </file>',
459
+ ].join('\n')).join('\n');
460
+
461
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<context>\n${fileEntries}\n</context>\n`;
372
462
  }
373
463
 
374
- function generateContextFromConfig(config, srcPath) {
375
- const spinner = ora(`Scanning ${srcPath}...`).start();
464
+ function escapeXml(str) {
465
+ if (str == null) return '';
466
+ return String(str)
467
+ .replace(/&/g, '&amp;')
468
+ .replace(/</g, '&lt;')
469
+ .replace(/>/g, '&gt;')
470
+ .replace(/"/g, '&quot;')
471
+ .replace(/'/g, '&apos;');
472
+ }
376
473
 
377
- if (!fs.existsSync(srcPath)) {
378
- spinner.fail(`Source path does not exist: ${srcPath}`);
379
- return null;
474
+ function renderFormat(format, files, stats, config) {
475
+ switch (format) {
476
+ case 'json': return { content: toJson(files), ext: 'json' };
477
+ case 'md': return { content: toMarkdown(files, config), ext: 'md' };
478
+ case 'toon': return { content: toToon(files, stats), ext: 'toon' };
479
+ case 'xml': return { content: toXml(files), ext: 'xml' };
380
480
  }
481
+ }
482
+
483
+ // ============================================
484
+ // CONTEXT GENERATION
485
+ // ============================================
381
486
 
382
- contextFiles = getFiles(srcPath, config);
487
+ function generateContext(config) {
488
+ const spinner = ora(`Scanning ${config.src}...`).start();
383
489
 
384
- if (contextFiles.length === 0) {
385
- spinner.fail(`No files found in: ${srcPath}`);
490
+ if (!fs.existsSync(config.src)) {
491
+ spinner.fail(`Source path does not exist: ${config.src}`);
386
492
  return null;
387
493
  }
388
494
 
389
- spinner.text = `Building context from ${contextFiles.length} files...`;
390
- generatedContext = buildContextContent(contextFiles, config, srcPath);
495
+ const { files, stats } = scanFiles(config.src, config);
391
496
 
392
- spinner.succeed(`Context generated: ${chalk.yellow(contextFiles.length)} files, ${chalk.yellow(formatSize(contextStats.totalSize))}`);
497
+ if (files.length === 0) {
498
+ spinner.fail(`No files found in: ${config.src}`);
499
+ return null;
500
+ }
393
501
 
394
- return {
395
- content: generatedContext,
396
- files: contextFiles,
397
- stats: contextStats,
398
- config
399
- };
502
+ spinner.succeed(`Context built: ${chalk.yellow(files.length)} files, ${chalk.yellow(formatSize(stats.totalSize))}`);
503
+ return { files, stats, config };
504
+ }
505
+
506
+ function printSummary(stats) {
507
+ console.log(chalk.cyan('\n📊 Context Summary:'));
508
+ console.log(chalk.white(` Files: ${stats.files}`));
509
+ console.log(chalk.white(` Lines: ${stats.totalLines.toLocaleString()}`));
510
+ console.log(chalk.white(` Size: ${formatSize(stats.totalSize)}`));
400
511
  }
401
512
 
402
513
  // ============================================
403
514
  // SAVE CONTEXT
404
515
  // ============================================
405
516
 
406
- async function saveContext(result) {
517
+ async function saveContext(result, formats, fileName) {
407
518
  loadDependencies();
408
519
 
409
- let outputPath;
410
-
411
- if (result.config.output) {
412
- outputPath = result.config.output;
413
- } else {
414
- const { savePath } = await inquirer.prompt([
415
- {
416
- type: 'input',
417
- name: 'savePath',
418
- message: 'Enter output directory:',
419
- default: './mkctx'
420
- }
421
- ]);
422
- outputPath = savePath;
520
+ if (!fileName) {
521
+ const answer = await inquirer.prompt([{
522
+ type: 'input', name: 'fileName',
523
+ message: 'Enter a name for the output files:',
524
+ default: 'context',
525
+ }]);
526
+ fileName = answer.fileName;
423
527
  }
424
528
 
425
- if (!fs.existsSync(outputPath)) {
426
- fs.mkdirSync(outputPath, { recursive: true });
427
- }
529
+ const outputDir = result.config.output || './mkctx';
530
+ fs.mkdirSync(outputDir, { recursive: true });
428
531
 
429
- const outputFile = path.join(outputPath, 'context.md');
430
- fs.writeFileSync(outputFile, result.content);
532
+ const savedFiles = [];
431
533
 
432
- console.log(chalk.green(`\n✅ Context saved to: ${chalk.yellow(outputFile)}`));
433
- console.log(chalk.gray(` ${result.stats.files} files, ${formatSize(result.stats.totalSize)}\n`));
534
+ for (const format of formats) {
535
+ const { content, ext } = renderFormat(format, result.files, result.stats, result.config);
536
+ const outputPath = path.join(outputDir, `${fileName}.${ext}`);
537
+ fs.writeFileSync(outputPath, content);
538
+ savedFiles.push({
539
+ format,
540
+ file: outputPath,
541
+ size: Buffer.byteLength(content, 'utf-8'),
542
+ tokens: estimateTokens(content),
543
+ });
544
+ }
434
545
 
435
- return outputFile;
546
+ console.log(chalk.green('\n✅ Context saved:\n'));
547
+ for (const { format, file, size, tokens } of savedFiles) {
548
+ console.log(chalk.white(` ${chalk.cyan(format.toUpperCase().padEnd(4))} → ${chalk.yellow(file)}`));
549
+ console.log(chalk.gray(` ${formatSize(size)} | ~${tokens.toLocaleString()} tokens\n`));
550
+ }
551
+
552
+ return savedFiles;
436
553
  }
437
554
 
438
555
  // ============================================
439
- // MAIN MENU
556
+ // INTERACTIVE PROMPTS
440
557
  // ============================================
441
558
 
442
- async function showMainMenu() {
443
- loadDependencies();
559
+ async function promptSrcPath() {
560
+ const { srcPath } = await inquirer.prompt([{
561
+ type: 'input', name: 'srcPath',
562
+ message: 'Enter the source path to analyze:',
563
+ default: '.',
564
+ validate: input => fs.existsSync(input) || `Path does not exist: ${input}`,
565
+ }]);
566
+ return srcPath;
567
+ }
568
+
569
+ async function promptFormat() {
570
+ const { format } = await inquirer.prompt([{
571
+ type: 'list', name: 'format',
572
+ message: 'Select output format:',
573
+ default: 'all',
574
+ choices: [
575
+ { name: chalk.magenta('📦 All formats (MD, JSON, TOON, XML)'), value: 'all' },
576
+ new inquirer.Separator(),
577
+ { name: chalk.blue('📝 Markdown (.md)'), value: 'md' },
578
+ { name: chalk.green('🔧 JSON (.json) - Simple array'), value: 'json' },
579
+ { name: chalk.yellow('🎒 TOON (.toon) - Token-optimized'), value: 'toon' },
580
+ { name: chalk.red('📄 XML (.xml)'), value: 'xml' },
581
+ ],
582
+ }]);
583
+ return resolveFormats(format);
584
+ }
444
585
 
445
- const hasConfig = hasProjectConfig();
586
+ async function promptMainMenu() {
587
+ const hasConfig = configFileExists();
446
588
 
447
589
  console.log(chalk.cyan('\n╔════════════════════════════════════════╗'));
448
590
  console.log(chalk.cyan('║') + chalk.cyan.bold(' 📄 mkctx - Make Context ') + chalk.cyan('║'));
@@ -451,155 +593,96 @@ async function showMainMenu() {
451
593
  const choices = [];
452
594
 
453
595
  if (hasConfig) {
454
- choices.push({
455
- name: chalk.green('📁 Generate from config file'),
456
- value: 'from-config'
457
- });
596
+ choices.push({ name: chalk.green('📁 Generate from config file'), value: 'from-config' });
458
597
  }
459
598
 
460
599
  choices.push(
461
- {
462
- name: chalk.blue('🔍 Generate dynamically (choose path)'),
463
- value: 'dynamic'
464
- },
600
+ { name: chalk.blue('🔍 Generate dynamically (choose path)'), value: 'dynamic' },
465
601
  new inquirer.Separator(),
466
602
  {
467
- name: hasConfig
468
- ? chalk.gray('⚙️ View configuration')
469
- : chalk.yellow('⚙️ Create configuration file'),
470
- value: 'config'
603
+ name: hasConfig ? chalk.gray('⚙️ View configuration') : chalk.yellow('⚙️ Create configuration file'),
604
+ value: 'config',
471
605
  },
472
606
  new inquirer.Separator(),
473
- {
474
- name: chalk.red('❌ Exit'),
475
- value: 'exit'
476
- }
607
+ { name: chalk.red('❌ Exit'), value: 'exit' },
477
608
  );
478
609
 
479
- const { action } = await inquirer.prompt([
480
- {
481
- type: 'list',
482
- name: 'action',
483
- message: 'What would you like to do?',
484
- choices
485
- }
486
- ]);
487
-
488
- return action;
489
- }
490
-
491
- async function showPostGenerationMenu(result) {
492
- loadDependencies();
493
-
494
- console.log(chalk.cyan('\n📊 Context Summary:'));
495
- console.log(chalk.white(` Files: ${result.stats.files}`));
496
- console.log(chalk.white(` Lines: ${result.stats.totalLines.toLocaleString()}`));
497
- console.log(chalk.white(` Size: ${formatSize(result.stats.totalSize)}`));
498
- console.log(chalk.white(` Est. tokens: ~${result.stats.estimatedTokens.toLocaleString()}`));
499
-
500
- const { action } = await inquirer.prompt([
501
- {
502
- type: 'list',
503
- name: 'action',
504
- message: 'What would you like to do with this context?',
505
- choices: [
506
- {
507
- name: chalk.blue('💾 Save context to file'),
508
- value: 'save'
509
- },
510
- new inquirer.Separator(),
511
- {
512
- name: chalk.gray('🔙 Back to main menu'),
513
- value: 'back'
514
- },
515
- {
516
- name: chalk.red('❌ Exit'),
517
- value: 'exit'
518
- }
519
- ]
520
- }
521
- ]);
610
+ const { action } = await inquirer.prompt([{
611
+ type: 'list', name: 'action',
612
+ message: 'What would you like to do?',
613
+ choices,
614
+ }]);
522
615
 
523
616
  return action;
524
617
  }
525
618
 
526
619
  // ============================================
527
- // MAIN APPLICATION
620
+ // EXECUTION FLOWS
528
621
  // ============================================
529
622
 
530
- async function main() {
531
- const args = process.argv.slice(2);
623
+ async function runNonInteractive(rawFlags) {
624
+ loadDependencies();
532
625
 
533
- if (args.includes('--help') || args.includes('-h') || args[0] === 'help') {
534
- showHelp();
535
- return;
536
- }
626
+ const cli = resolveFlags(rawFlags);
627
+ const config = buildConfig(cli);
628
+ const formats = resolveFormats(cli.format);
629
+ const fileName = cli.name || 'context';
537
630
 
538
- if (args.includes('--version') || args.includes('-v') || args[0] === 'version') {
539
- showVersion();
540
- return;
631
+ if (!fs.existsSync(config.src)) {
632
+ console.error(`❌ Source path does not exist: ${config.src}`);
633
+ process.exit(1);
541
634
  }
542
635
 
543
- if (args[0] === 'config') {
544
- loadDependencies();
545
- createProjectConfig();
546
- return;
547
- }
636
+ const result = generateContext(config);
637
+ if (!result) process.exit(1);
638
+
639
+ printSummary(result.stats);
640
+ await saveContext(result, formats, fileName);
641
+ console.log(chalk.yellow('👋 Done!\n'));
642
+ }
548
643
 
644
+ async function runInteractive() {
549
645
  loadDependencies();
550
646
 
551
647
  let running = true;
552
648
 
553
649
  while (running) {
554
- const action = await showMainMenu();
650
+ const action = await promptMainMenu();
555
651
 
556
652
  switch (action) {
557
653
  case 'from-config': {
558
- const result = await generateContextFromConfigFile();
559
- if (result) {
560
- let postAction = await showPostGenerationMenu(result);
561
-
562
- while (postAction !== 'back' && postAction !== 'exit') {
563
- if (postAction === 'save') {
564
- await saveContext(result);
565
- postAction = await showPostGenerationMenu(result);
566
- }
567
- }
568
-
569
- if (postAction === 'exit') {
570
- running = false;
571
- }
572
- }
654
+ const config = loadConfig();
655
+ if (!config) { console.log(chalk.yellow('\n⚠️ No config file found.')); break; }
656
+ const result = generateContext(config);
657
+ if (!result) break;
658
+ printSummary(result.stats);
659
+ const formats = await promptFormat();
660
+ await saveContext(result, formats);
661
+ console.log(chalk.yellow('👋 Done!\n'));
662
+ running = false;
573
663
  break;
574
664
  }
575
665
 
576
666
  case 'dynamic': {
577
- const result = await generateContextDynamic();
578
- if (result) {
579
- let postAction = await showPostGenerationMenu(result);
580
-
581
- while (postAction !== 'back' && postAction !== 'exit') {
582
- if (postAction === 'save') {
583
- await saveContext(result);
584
- postAction = await showPostGenerationMenu(result);
585
- }
586
- }
587
-
588
- if (postAction === 'exit') {
589
- running = false;
590
- }
591
- }
667
+ const srcPath = await promptSrcPath();
668
+ const config = { ...DEFAULT_CONFIG, src: srcPath };
669
+ const result = generateContext(config);
670
+ if (!result) break;
671
+ printSummary(result.stats);
672
+ const formats = await promptFormat();
673
+ await saveContext(result, formats);
674
+ console.log(chalk.yellow('👋 Done!\n'));
675
+ running = false;
592
676
  break;
593
677
  }
594
678
 
595
679
  case 'config':
596
- if (hasProjectConfig()) {
680
+ if (configFileExists()) {
597
681
  console.log(chalk.cyan('\n📄 Current configuration:\n'));
598
- const config = loadProjectConfig();
599
- console.log(JSON.stringify(config, null, 2));
682
+ console.log(JSON.stringify(loadConfig(), null, 2));
600
683
  console.log(chalk.gray(`\n Edit ${CONFIG_FILE} to modify settings.\n`));
601
684
  } else {
602
- createProjectConfig();
685
+ createConfigFile();
603
686
  }
604
687
  break;
605
688
 
@@ -611,9 +694,12 @@ async function main() {
611
694
  }
612
695
  }
613
696
 
697
+ // ============================================
698
+ // HELP & VERSION
699
+ // ============================================
700
+
614
701
  function showHelp() {
615
702
  loadDependencies();
616
-
617
703
  console.log(chalk.cyan(`
618
704
  ╔════════════════════════════════════════════════════════════╗
619
705
  ║ 📄 mkctx - Make Context for AI Code Analysis ║
@@ -622,17 +708,34 @@ function showHelp() {
622
708
  ${chalk.white('Generate context files from your codebase for AI analysis.')}
623
709
 
624
710
  ${chalk.yellow('Usage:')}
625
- mkctx Interactive mode (recommended)
626
- mkctx config Create configuration file
627
- mkctx help Show this help message
628
- mkctx version Show version
629
-
630
- ${chalk.yellow('Configuration (mkctx.config.json):')}
631
- src Source directory to scan (default: ".")
632
- ignore Comma-separated patterns to ignore
633
- output Output directory (default: "./mkctx")
634
- first_comment Comment at the beginning of context
635
- last_comment Comment at the end of context
711
+ mkctx Interactive mode
712
+ mkctx config Create configuration file
713
+ mkctx help / --help Show this help message
714
+ mkctx version / --version Show version
715
+
716
+ ${chalk.yellow('Non-interactive flags (skip all prompts):')}
717
+ --src <path> Source directory (default: .)
718
+ --output <path> Output directory (default: ./mkctx)
719
+ --format <fmt> md, json, toon, xml, all, or comma-separated
720
+ --name <filename> Output file base name (default: context)
721
+ --ignore <patterns> Comma-separated ignore patterns
722
+ --first-comment <text> Override first comment header
723
+ --last-comment <text> Override last comment footer
724
+
725
+ ${chalk.yellow('Short aliases:')}
726
+ -s --src -o --output -f --format -n --name
727
+
728
+ ${chalk.yellow('Examples:')}
729
+ mkctx --src ./src
730
+ mkctx --src . --format all --name my-project --output ./docs
731
+ mkctx --src ./app --format md,json --ignore "*.test.ts,__tests__/"
732
+ mkctx -s ./src -f toon -n snapshot
733
+
734
+ ${chalk.yellow('Output Formats:')}
735
+ ${chalk.green('JSON')} Simple array of file objects
736
+ ${chalk.blue('MD')} Markdown with code blocks
737
+ ${chalk.yellow('TOON')} Token-Oriented Object Notation (LLM optimized)
738
+ ${chalk.red('XML')} XML with CDATA sections
636
739
 
637
740
  ${chalk.gray('More info: https://github.com/pnkkzero/mkctx')}
638
741
  `));
@@ -643,18 +746,30 @@ function showVersion() {
643
746
  const pkg = require('./package.json');
644
747
  console.log(`mkctx v${pkg.version}`);
645
748
  } catch {
646
- console.log('mkctx v3.0.0');
749
+ console.log('mkctx v4.0.0');
647
750
  }
648
751
  }
649
752
 
650
753
  // ============================================
651
- // RUN
754
+ // ENTRY POINT
652
755
  // ============================================
653
756
 
654
- main().catch((err) => {
655
- console.error(`\n❌ Error: ${err.message}`);
656
- if (process.env.DEBUG) {
657
- console.error(err.stack);
757
+ async function main() {
758
+ const { command, flags } = parseArgs(process.argv);
759
+
760
+ if (flags.help || flags.h || command === 'help') return showHelp();
761
+ if (flags.version || flags.v || command === 'version') return showVersion();
762
+ if (command === 'config') { loadDependencies(); return createConfigFile(); }
763
+
764
+ if (isNonInteractiveMode(flags)) {
765
+ await runNonInteractive(flags);
766
+ } else {
767
+ await runInteractive();
658
768
  }
769
+ }
770
+
771
+ main().catch(err => {
772
+ console.error(`\n❌ Error: ${err.message}`);
773
+ if (process.env.DEBUG) console.error(err.stack);
659
774
  process.exit(1);
660
775
  });