ctxman 3.1.0 → 3.1.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.
package/README.md CHANGED
@@ -322,7 +322,8 @@ ctxman mcp # run the server (stdio)
322
322
  The installer merges into the client's configuration and keeps a `.bak` of the previous file.
323
323
  `--root` confines every tool to one directory. Tools: `analyze_codebase`, `generate_context`,
324
324
  `git_diff`, `search_code`, `list_methods`, `repo_map`, `pr_context`, `get_symbol`,
325
- `count_tokens`, `explain_history`, `pack_context`. Details:
325
+ `count_tokens`, `explain_history`, `pack_context`, `check_policy`, `audit_log`, `ask_codebase`.
326
+ Details:
326
327
  [docs/commands/mcp.md](docs/commands/mcp.md).
327
328
 
328
329
  ### 🛡️ Export Policy and Audit Log
package/bin/cm-ask.js CHANGED
@@ -1,10 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { VectorStoreFactory } from '../lib/rag/VectorStoreFactory.js';
3
- import { TransformersEmbeddingProvider } from '../lib/rag/EmbeddingProvider.js';
4
- import { Indexer } from '../lib/rag/Indexer.js';
5
- import { FileDataSource } from '../lib/rag/sources/FileDataSource.js';
6
- import path from 'path';
7
- import fs from 'fs/promises';
2
+ import { askCodebase } from '../lib/rag/ask.js';
8
3
 
9
4
  async function main() {
10
5
  const args = process.argv.slice(2);
@@ -15,86 +10,37 @@ async function main() {
15
10
  process.exit(1);
16
11
  }
17
12
 
18
- // 1. Initialize RAG Components
19
- let provider;
20
- try {
21
- // Try to load local transformers
22
- provider = new TransformersEmbeddingProvider();
23
- // Lazy init will handle model download on first use
24
- } catch (e) {
25
- console.error('⚠️ Failed to initialize embedding provider:', e.message);
26
- process.exit(1);
27
- }
28
-
29
- // Use LanceDB in .ctxman/self-knowledge
30
- const storePath = path.join(process.cwd(), '.ctxman', 'self-knowledge');
31
- const store = VectorStoreFactory.create('lancedb', provider, { path: storePath });
32
-
33
- // 2. Check if indexed
34
- const isIndexed = await checkIndexExists(storePath);
35
-
36
- if (!isIndexed) {
37
- console.log('📚 Index not found. Auto-indexing current project...');
38
- await indexProject(store);
39
- }
40
-
41
- // 3. Search
42
13
  console.log(`🔍 Searching for: "${query}"...`);
43
- const results = await store.search(query, 5); // Get top 5
14
+ const { results, removedByPolicy } = await askCodebase(process.cwd(), query, {
15
+ onProgress: (event) => {
16
+ if (event === 'indexing') {
17
+ console.log('📚 Index not found. Auto-indexing current project...');
18
+ console.log('⏳ Indexing files (this may take a moment)...');
19
+ } else if (event === 'indexed') {
20
+ console.log('✅ Indexing complete.');
21
+ }
22
+ },
23
+ });
44
24
 
25
+ if (removedByPolicy > 0) {
26
+ console.log(`🛡️ Removed ${removedByPolicy} results denied by the export policy`);
27
+ }
45
28
  if (results.length === 0) {
46
29
  console.log('❌ No relevant context found.');
47
30
  return;
48
31
  }
49
32
 
50
- // 4. Display Results (or feed to LLM if we had one configured)
51
33
  console.log(`\nFound ${results.length} relevant snippets:\n`);
52
-
53
34
  results.forEach((r, i) => {
54
- console.log(`[${i + 1}] ${r.metadata.filename} (Score: ${r.score?.toFixed(4)})`);
35
+ console.log(`[${i + 1}] ${r.file} (Score: ${r.score?.toFixed(4)})`);
55
36
  console.log('─'.repeat(40));
56
- // Show a small snippet around the match if possible, but we stored full text.
57
- // Let's just show the first 200 chars or so.
58
37
  console.log(r.text.substring(0, 300).replace(/\n/g, ' ') + '...');
59
38
  console.log('\n');
60
39
  });
61
-
62
- // Future: Pass `results` to an LLM API to generate a real answer.
63
- // For now, this validates the retrieval pipeline works from CLI.
64
- }
65
-
66
- async function checkIndexExists(storePath) {
67
- try {
68
- await fs.access(storePath);
69
- // Also check if it has tables (LanceDB creates folders)
70
- const files = await fs.readdir(storePath);
71
- return files.length > 0;
72
- } catch {
73
- return false;
74
- }
75
- }
76
-
77
- async function indexProject(store) {
78
- const indexer = new Indexer(store);
79
- const fileSource = new FileDataSource();
80
- indexer.registerSource(fileSource);
81
-
82
- console.log('⏳ Indexing files (this may take a moment)...');
83
- try {
84
- await indexer.index('local-files', {
85
- targetDir: process.cwd(),
86
- include: ['.js', '.md', '.ts', '.json'], // Configurable?
87
- exclude: ['node_modules', '.git', 'dist', 'coverage', '.ctxman'],
88
- });
89
- } catch (error) {
90
- console.error(`❌ ${error.message}`);
91
- process.exit(1);
92
- }
93
- console.log('✅ Indexing complete.');
94
40
  }
95
41
 
96
42
  main().catch((err) => {
97
- // Message only: missing optional dependencies surface here with an install hint
43
+ // Message only: missing optional dependencies and indexing failures surface here
98
44
  console.error('❌ Error:', err.message);
99
45
  process.exit(1);
100
46
  });
package/bin/cm-audit.js CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  import path from 'path';
8
8
  import {
9
- loadPolicy,
9
+ auditLogFiles,
10
10
  readAuditLog,
11
11
  filterAuditEvents,
12
12
  formatAuditTable,
@@ -84,15 +84,12 @@ function main() {
84
84
 
85
85
  // The policy's audit logs; the default location when no policy names one
86
86
  function auditFiles() {
87
- const root = process.cwd();
88
- try {
89
- const policy = loadPolicy(root);
90
- if (policy.audit.paths.length > 0) return policy.audit.paths;
91
- } catch (error) {
87
+ const { files, error } = auditLogFiles(process.cwd());
88
+ if (error) {
92
89
  console.error(`⚠️ ${error.message}`);
93
90
  console.error(` Reading the default log ${DEFAULT_AUDIT_PATH} instead.`);
94
91
  }
95
- return [path.join(root, DEFAULT_AUDIT_PATH)];
92
+ return files;
96
93
  }
97
94
 
98
95
  function valueOf(args, flag) {
@@ -24,7 +24,7 @@ jobs:
24
24
 
25
25
  steps:
26
26
  - name: Checkout repository
27
- uses: actions/checkout@v4
27
+ uses: actions/checkout@v7
28
28
  with:
29
29
  persist-credentials: false
30
30
  # changed-since needs the base commit of the pull request in the checkout
@@ -96,9 +96,13 @@ export class PerformanceDashboard {
96
96
  }
97
97
 
98
98
  const metricsFile = path.join(this.storagePath, 'metrics.json');
99
- const data = fs.existsSync(metricsFile)
100
- ? JSON.parse(fs.readFileSync(metricsFile, 'utf8'))
101
- : [];
99
+ let data = [];
100
+ try {
101
+ data = JSON.parse(fs.readFileSync(metricsFile, 'utf8'));
102
+ } catch (error) {
103
+ // No metrics yet; an unreadable file stops the save, as before
104
+ if (error.code !== 'ENOENT') throw error;
105
+ }
102
106
 
103
107
  data.push(...this.metrics);
104
108
  fs.writeFileSync(metricsFile, JSON.stringify(data, null, 2));
@@ -23,7 +23,16 @@ import { getSymbol } from '../../search/symbols.js';
23
23
  import { countForModel } from '../../count/index.js';
24
24
  import { explainHistory } from '../../why/index.js';
25
25
  import { packContext, describePack, TOOL_DEFAULT_BUDGET } from '../../pack/index.js';
26
- import { loadPolicy, guardExport } from '../../policy/index.js';
26
+ import {
27
+ loadPolicy,
28
+ guardExport,
29
+ PolicyConfigError,
30
+ auditLogFiles,
31
+ readAuditLog,
32
+ filterAuditEvents,
33
+ parseSince,
34
+ } from '../../policy/index.js';
35
+ import { askCodebase, DEFAULT_ASK_LIMIT } from '../../rag/ask.js';
27
36
  import fs from 'fs';
28
37
  import path from 'path';
29
38
  import { createRequire } from 'module';
@@ -33,6 +42,10 @@ const { version } = createRequire(import.meta.url)('../../../package.json');
33
42
  // Tools whose `path` may also name a single file
34
43
  const FILE_PATH_TOOLS = new Set(['list_methods']);
35
44
 
45
+ const DEFAULT_AUDIT_LIMIT = 50;
46
+ // Characters of each ask_codebase snippet (the index stores whole files)
47
+ const ASK_SNIPPET_CHARS = 2000;
48
+
36
49
  class MCPServer {
37
50
  /**
38
51
  * @param {Object} [options]
@@ -340,6 +353,56 @@ class MCPServer {
340
353
  required: ['path'],
341
354
  },
342
355
  },
356
+ {
357
+ name: 'check_policy',
358
+ description:
359
+ 'The effective export policy of a project (.ctxman/policy.json plus the organisation policy named by CTXMAN_POLICY): denied paths, redaction and token limits, allowed target models, AI-tool ignore files and audit logging. Reports whether the policy files are valid and, if not, every error.',
360
+ inputSchema: {
361
+ type: 'object',
362
+ properties: {
363
+ path: { type: 'string', description: 'Project root' },
364
+ },
365
+ required: ['path'],
366
+ },
367
+ },
368
+ {
369
+ name: 'audit_log',
370
+ description:
371
+ "Exports recorded in the project's audit log while the export policy enables audit: when, by whom, which command, where to, which files, token count and a SHA-256 of the exported text. Newest last.",
372
+ inputSchema: {
373
+ type: 'object',
374
+ properties: {
375
+ path: { type: 'string', description: 'Project root' },
376
+ since: {
377
+ type: 'string',
378
+ description:
379
+ 'Only exports at or after this date (2026-10-01), ISO time or age (30m, 12h, 7d, 2w)',
380
+ },
381
+ limit: {
382
+ type: 'number',
383
+ description: `The last N events (default ${DEFAULT_AUDIT_LIMIT}; 0 = all)`,
384
+ },
385
+ },
386
+ required: ['path'],
387
+ },
388
+ },
389
+ {
390
+ name: 'ask_codebase',
391
+ description:
392
+ 'Semantic search: the files whose content is closest in meaning to a question, from a local embedding index (.ctxman/self-knowledge, built on first use; the first call downloads the embedding model and can take minutes). Needs the optional @xenova/transformers and @lancedb/lancedb packages. For exact names or keywords, search_code is faster.',
393
+ inputSchema: {
394
+ type: 'object',
395
+ properties: {
396
+ path: { type: 'string', description: 'Project root' },
397
+ query: { type: 'string', description: 'The question, in natural language' },
398
+ limit: {
399
+ type: 'number',
400
+ description: `How many files (default ${DEFAULT_ASK_LIMIT})`,
401
+ },
402
+ },
403
+ required: ['path', 'query'],
404
+ },
405
+ },
343
406
  ],
344
407
  };
345
408
  });
@@ -666,6 +729,40 @@ class MCPServer {
666
729
  ],
667
730
  };
668
731
  }
732
+ case 'check_policy': {
733
+ let result;
734
+ try {
735
+ result = { valid: true, ...loadPolicy(args.path).toJSON() };
736
+ } catch (error) {
737
+ if (!(error instanceof PolicyConfigError)) throw error;
738
+ result = { valid: false, file: error.file, errors: error.errors };
739
+ }
740
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
741
+ }
742
+ case 'audit_log': {
743
+ const since = args.since === undefined ? null : parseSince(String(args.since));
744
+ const limit =
745
+ Number.isInteger(args.limit) && args.limit >= 0 ? args.limit : DEFAULT_AUDIT_LIMIT;
746
+ // Only the policy's own logs: a tool argument never names a file to read
747
+ const { files, error } = auditLogFiles(args.path);
748
+ const { events, skipped, missing } = readAuditLog(files);
749
+ const matching = filterAuditEvents(events, { since });
750
+ const result = {
751
+ files: files.filter((file) => !missing.includes(file)),
752
+ total: matching.length,
753
+ events: filterAuditEvents(matching, { limit }),
754
+ skippedLines: skipped,
755
+ };
756
+ if (error) result.policyError = error.message;
757
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
758
+ }
759
+ case 'ask_codebase': {
760
+ const result = await askCodebase(args.path, args.query, {
761
+ limit: Number.isInteger(args.limit) ? args.limit : undefined,
762
+ maxChars: ASK_SNIPPET_CHARS,
763
+ });
764
+ return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
765
+ }
669
766
  default:
670
767
  throw new Error(`Unknown tool: ${name}`);
671
768
  }
@@ -43,7 +43,10 @@ class GitIgnoreParser {
43
43
  } catch (error) {
44
44
  // A directory, or a file that doesn't exist or can't be read
45
45
  if (error.code === 'EISDIR') {
46
- console.warn(`Warning: ${filePath} is a directory, expected a file`);
46
+ // Quoted and escaped: a line break in the path cannot start a line of its own
47
+ console.warn(
48
+ `Warning: ${JSON.stringify(String(filePath))} is a directory, expected a file`
49
+ );
47
50
  }
48
51
  return [];
49
52
  }
@@ -12,7 +12,7 @@ import os from 'os';
12
12
  import path from 'path';
13
13
  import { execFileSync } from 'child_process';
14
14
  import { createRequire } from 'module';
15
- import { relativePathOf } from './policy.js';
15
+ import { relativePathOf, loadPolicy, DEFAULT_AUDIT_PATH } from './policy.js';
16
16
 
17
17
  const { version: CTXMAN_VERSION } = createRequire(import.meta.url)('../../package.json');
18
18
 
@@ -167,6 +167,24 @@ export function recordExport(policy, event = {}, { warn = defaultWarn } = {}) {
167
167
  return record;
168
168
  }
169
169
 
170
+ /**
171
+ * The audit logs of a project's policy, or the default location when the policy names none
172
+ * @param {string} root - Project root
173
+ * @param {Object} [options]
174
+ * @param {object} [options.env=process.env] - Environment for loading the policy
175
+ * @returns {{files: string[], error: Error|null}} `error` when the policy could not be
176
+ * loaded (the default location is returned then)
177
+ */
178
+ export function auditLogFiles(root, { env = process.env } = {}) {
179
+ try {
180
+ const policy = loadPolicy(root, { env });
181
+ if (policy.audit.paths.length > 0) return { files: policy.audit.paths, error: null };
182
+ return { files: [path.join(root, DEFAULT_AUDIT_PATH)], error: null };
183
+ } catch (error) {
184
+ return { files: [path.join(root, DEFAULT_AUDIT_PATH)], error };
185
+ }
186
+ }
187
+
170
188
  /**
171
189
  * Read audit logs. Lines that are not JSON objects are skipped and counted; events that
172
190
  * appear in several logs (organisation and project) are listed once.
@@ -38,6 +38,7 @@ export {
38
38
  recordExport,
39
39
  buildAuditRecord,
40
40
  readAuditLog,
41
+ auditLogFiles,
41
42
  filterAuditEvents,
42
43
  formatAuditTable,
43
44
  parseSince,
package/lib/rag/ask.js ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Semantic search over a project (`ctxman ask` and the MCP `ask_codebase` tool): an
3
+ * embedding index under .ctxman/self-knowledge, built on first use, then the snippets
4
+ * closest to the question. Needs the optional @xenova/transformers and @lancedb/lancedb
5
+ * packages; the embedding model is downloaded on first use.
6
+ *
7
+ * Files left out by .gitignore, .contextignore, .contextinclude or the export policy are
8
+ * not indexed, and results from an older index are checked against the policy again.
9
+ */
10
+
11
+ import fs from 'fs/promises';
12
+ import path from 'path';
13
+ import { Scanner } from '../core/Scanner.js';
14
+ import { redactSecrets } from '../utils/secret-redactor.js';
15
+ import { VectorStoreFactory } from './VectorStoreFactory.js';
16
+ import { TransformersEmbeddingProvider } from './EmbeddingProvider.js';
17
+ import { Indexer } from './Indexer.js';
18
+ import { FileDataSource } from './sources/FileDataSource.js';
19
+
20
+ export const ASK_STORE_DIR = path.join('.ctxman', 'self-knowledge');
21
+ export const DEFAULT_ASK_LIMIT = 5;
22
+
23
+ const INCLUDE = ['.js', '.md', '.ts', '.json'];
24
+ const EXCLUDE = ['node_modules', '.git', 'dist', 'coverage', '.ctxman'];
25
+ // Extra rows asked of the store, so results the policy removes still leave `limit`
26
+ const OVERFETCH = 10;
27
+
28
+ async function hasIndex(storePath) {
29
+ try {
30
+ return (await fs.readdir(storePath)).length > 0;
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
35
+
36
+ const toPosix = (p) => p.split(path.sep).join('/');
37
+
38
+ /**
39
+ * @param {string} root - Project directory
40
+ * @param {string} query - The question
41
+ * @param {Object} [options]
42
+ * @param {number} [options.limit=5] - Snippets to return
43
+ * @param {boolean} [options.redact=true] - Redact secrets in the snippets
44
+ * @param {number} [options.maxChars] - Cut each snippet to this many characters
45
+ * @param {(event: 'indexing'|'indexed') => void} [options.onProgress]
46
+ * @param {(storePath: string) => object} [options.createStore] - Vector store factory
47
+ * (tests); default: LanceDB with the local embedding model
48
+ * @returns {Promise<{query: string, indexed: boolean, results: Array<{file: string,
49
+ * score: number|null, text: string, truncated: boolean}>, removedByPolicy: number,
50
+ * redactions: number}>}
51
+ * @throws {Error} When the question is empty, the policy is invalid, the optional packages
52
+ * are missing or indexing fails
53
+ */
54
+ export async function askCodebase(root, query, options = {}) {
55
+ const question = typeof query === 'string' ? query.trim() : '';
56
+ if (!question) throw new Error('A question is required');
57
+ const limit =
58
+ Number.isInteger(options.limit) && options.limit > 0 ? options.limit : DEFAULT_ASK_LIMIT;
59
+ const projectRoot = path.resolve(root);
60
+ const storePath = path.join(projectRoot, ASK_STORE_DIR);
61
+ const onProgress = options.onProgress || (() => {});
62
+
63
+ const scanner = new Scanner(projectRoot);
64
+ // Load now: an invalid policy fails the command instead of being skipped
65
+ scanner.getPolicy();
66
+ const relative = (fullPath) => toPosix(path.relative(projectRoot, fullPath));
67
+ const left = (fullPath) => {
68
+ const rel = relative(fullPath);
69
+ return rel.startsWith('../') || path.isAbsolute(rel);
70
+ };
71
+
72
+ const store = options.createStore
73
+ ? options.createStore(storePath)
74
+ : VectorStoreFactory.create('lancedb', new TransformersEmbeddingProvider(), {
75
+ path: storePath,
76
+ });
77
+
78
+ let indexed = false;
79
+ if (!(await hasIndex(storePath))) {
80
+ onProgress('indexing');
81
+ const indexer = new Indexer(store);
82
+ indexer.registerSource(new FileDataSource());
83
+ await indexer.index('local-files', {
84
+ targetDir: projectRoot,
85
+ include: INCLUDE,
86
+ exclude: EXCLUDE,
87
+ skip: (fullPath) =>
88
+ scanner.shouldIgnore(path.relative(projectRoot, fullPath), fullPath) ||
89
+ scanner.isDenied(relative(fullPath)),
90
+ });
91
+ indexed = true;
92
+ onProgress('indexed');
93
+ }
94
+
95
+ const rows = await store.search(question, limit + OVERFETCH);
96
+ const results = [];
97
+ let removedByPolicy = 0;
98
+ let redactions = 0;
99
+ for (const row of rows) {
100
+ const fullPath = row.metadata?.path;
101
+ if (typeof fullPath !== 'string' || left(fullPath)) continue;
102
+ const file = relative(fullPath);
103
+ if (scanner.isDenied(file)) {
104
+ removedByPolicy++;
105
+ continue;
106
+ }
107
+ if (results.length === limit) continue;
108
+ let text = String(row.text ?? '');
109
+ if (options.redact !== false) {
110
+ const redacted = redactSecrets(text);
111
+ text = redacted.text;
112
+ redactions += redacted.findings.length;
113
+ }
114
+ const truncated = Boolean(options.maxChars) && text.length > options.maxChars;
115
+ if (truncated) text = text.slice(0, options.maxChars);
116
+ results.push({ file, score: row.score ?? null, text, truncated });
117
+ }
118
+
119
+ return { query: question, indexed, results, removedByPolicy, redactions };
120
+ }
121
+
122
+ export default askCodebase;
@@ -19,11 +19,13 @@ export class FileDataSource extends DataSourcePlugin {
19
19
  const maxDepth = options.maxDepth || 5;
20
20
  const include = options.include || ['.js', '.md', '.ts', '.json'];
21
21
  const exclude = options.exclude || ['node_modules', '.git', 'dist', 'coverage'];
22
+ // (fullPath, isDirectory) => true leaves a file or directory out
23
+ const skip = options.skip || (() => false);
22
24
 
23
- yield* this.walkDirectory(targetDir, maxDepth, include, exclude);
25
+ yield* this.walkDirectory(targetDir, maxDepth, include, exclude, skip);
24
26
  }
25
27
 
26
- async *walkDirectory(dir, depth, include, exclude) {
28
+ async *walkDirectory(dir, depth, include, exclude, skip = () => false) {
27
29
  if (depth < 0) return;
28
30
 
29
31
  let entries;
@@ -38,9 +40,10 @@ export class FileDataSource extends DataSourcePlugin {
38
40
  const fullPath = path.join(dir, entry.name);
39
41
 
40
42
  if (exclude.some((ex) => fullPath.includes(ex))) continue;
43
+ if (skip(fullPath, entry.isDirectory())) continue;
41
44
 
42
45
  if (entry.isDirectory()) {
43
- yield* this.walkDirectory(fullPath, depth - 1, include, exclude);
46
+ yield* this.walkDirectory(fullPath, depth - 1, include, exclude, skip);
44
47
  } else if (entry.isFile()) {
45
48
  const ext = path.extname(entry.name);
46
49
  if (include.length > 0 && !include.includes(ext)) continue;
@@ -94,13 +94,16 @@ class Logger {
94
94
  formatMessage(level, message, meta = {}) {
95
95
  const timestamp = this.getTimestamp();
96
96
  const icon = this.icons[level];
97
+ // One entry per line: line breaks in the message (a path, an error text) are written
98
+ // escaped, so the message cannot add entries of its own
99
+ const text = String(message).replace(/\r\n|\r|\n/g, '\\n');
97
100
 
98
101
  // Console format (with colors)
99
- const consoleMessage = `${this.colors[level]}${icon} [${level.toUpperCase()}]${this.colors.reset} ${message}`;
102
+ const consoleMessage = `${this.colors[level]}${icon} [${level.toUpperCase()}]${this.colors.reset} ${text}`;
100
103
 
101
104
  // File format (plain text with metadata)
102
105
  const metaString = Object.keys(meta).length > 0 ? ` | ${JSON.stringify(meta)}` : '';
103
- const fileMessage = `[${timestamp}] [${level.toUpperCase()}] ${message}${metaString}`;
106
+ const fileMessage = `[${timestamp}] [${level.toUpperCase()}] ${text}${metaString}`;
104
107
 
105
108
  return { consoleMessage, fileMessage };
106
109
  }
@@ -81,37 +81,39 @@ class ConfigGenerator {
81
81
  }
82
82
 
83
83
  const filePath = path.join(this.projectRoot, filename);
84
- const exists = fs.existsSync(filePath);
85
-
86
- // Check if should skip
87
- if (exists && !force) {
84
+ const skip = () =>
88
85
  result.skipped.push({
89
86
  type,
90
87
  filename,
91
88
  reason: 'File already exists (use --force to overwrite)',
92
89
  });
93
- continue;
94
- }
95
-
96
- // Generate file content
97
- const content = this.generateFileContent(type, patterns);
98
90
 
99
91
  if (dryRun) {
100
- if (exists) {
101
- result.overwritten.push({ type, filename, content });
102
- } else {
103
- result.created.push({ type, filename, content });
92
+ const exists = fs.existsSync(filePath);
93
+ if (exists && !force) {
94
+ skip();
95
+ continue;
104
96
  }
97
+ const content = this.generateFileContent(type, patterns);
98
+ (exists ? result.overwritten : result.created).push({ type, filename, content });
105
99
  continue;
106
100
  }
107
101
 
108
- // Write file
102
+ const content = this.generateFileContent(type, patterns);
103
+ // Create exclusively first, so an existing file is found by the write itself rather
104
+ // than by a separate check that could be out of date
109
105
  try {
110
- fs.writeFileSync(filePath, content, 'utf-8');
111
- if (exists) {
112
- result.overwritten.push({ type, filename });
113
- } else {
106
+ try {
107
+ fs.writeFileSync(filePath, content, { encoding: 'utf-8', flag: 'wx' });
114
108
  result.created.push({ type, filename });
109
+ } catch (error) {
110
+ if (error.code !== 'EEXIST') throw error;
111
+ if (!force) {
112
+ skip();
113
+ continue;
114
+ }
115
+ fs.writeFileSync(filePath, content, 'utf-8');
116
+ result.overwritten.push({ type, filename });
115
117
  }
116
118
  } catch (error) {
117
119
  result.errors.push({
@@ -514,7 +514,8 @@ class ProjectDetector {
514
514
  * @returns {boolean} True if matches
515
515
  */
516
516
  matchesGlob(filename, pattern) {
517
- const regex = pattern.replace(/\./g, '\\.').replace(/\*/g, '.*');
517
+ // Every regex character is literal except `*`, which matches any run of characters
518
+ const regex = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
518
519
  return new RegExp(`^${regex}$`).test(filename);
519
520
  }
520
521
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ctxman",
3
- "version": "3.1.0",
3
+ "version": "3.1.1",
4
4
  "description": "AI Development Platform with plugin architecture, Git integration, REST API, watch mode, LLM optimization, and support for 14+ languages. Features include TOON format (40-50% token reduction), real-time analysis, and comprehensive context generation.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -83,7 +83,7 @@
83
83
  ],
84
84
  "author": "Hakkı Sağdıç",
85
85
  "license": "MIT",
86
- "homepage": "https://github.com/hakkisagdic/ctxman#readme",
86
+ "homepage": "https://hakkisagdic.github.io/ctxman/",
87
87
  "repository": {
88
88
  "type": "git",
89
89
  "url": "git+https://github.com/hakkisagdic/ctxman.git"