ctxman 3.0.0 → 3.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.
Files changed (223) hide show
  1. package/.ctxman/llm-profiles.json +1067 -39
  2. package/.ctxman/profiles/backend-team.json +13 -0
  3. package/.ctxman/profiles/default.json +12 -0
  4. package/.ctxman/profiles/devops-team.json +21 -0
  5. package/.ctxman/profiles/frontend-team.json +20 -0
  6. package/.ctxman/templates/bug-fix.json +17 -0
  7. package/.ctxman/templates/code-review.json +9 -0
  8. package/.ctxman/templates/documentation.json +9 -0
  9. package/.ctxman/templates/feature.json +9 -0
  10. package/.ctxman/templates/full-context.json +9 -0
  11. package/.ctxman/templates/refactoring.json +18 -0
  12. package/.ctxman/templates/testing.json +9 -0
  13. package/.ctxman/wizard-profiles/security-audit/profile.json +7 -1
  14. package/LICENSE +21 -0
  15. package/README.md +380 -136
  16. package/bin/cli.js +1880 -529
  17. package/bin/cm-ask.js +18 -13
  18. package/bin/cm-audit.js +124 -0
  19. package/bin/cm-count.js +151 -0
  20. package/bin/cm-gitingest.js +128 -115
  21. package/bin/cm-map.js +242 -0
  22. package/bin/cm-mcp.js +293 -0
  23. package/bin/cm-pack.js +405 -0
  24. package/bin/cm-policy.js +170 -0
  25. package/bin/cm-pr.js +248 -0
  26. package/bin/cm-search.js +178 -0
  27. package/bin/cm-update.js +128 -128
  28. package/bin/cm-why.js +197 -0
  29. package/bin/mcp-server.js +8 -24
  30. package/ctxman.js +237 -183
  31. package/examples/README.md +19 -4
  32. package/examples/github-actions/ctxman-check.yml +49 -0
  33. package/examples/wizard-profiles/security-audit/profile.json +7 -1
  34. package/index.d.ts +1422 -0
  35. package/index.js +55 -31
  36. package/lib/agents/pr-failure-agent.js +48 -45
  37. package/lib/analyzers/ai-suggester.js +451 -0
  38. package/lib/analyzers/dependency-scanner.js +505 -0
  39. package/lib/analyzers/duplicate-detector.js +264 -0
  40. package/lib/analyzers/go-method-analyzer.js +151 -110
  41. package/lib/analyzers/import-tracker.js +431 -0
  42. package/lib/analyzers/method-analyzer.js +1055 -434
  43. package/lib/analyzers/performance-dashboard.js +683 -0
  44. package/lib/analyzers/token-calculator.js +895 -504
  45. package/lib/analyzers/unused-exports-detector.js +336 -0
  46. package/lib/api/mcp/install.js +424 -0
  47. package/lib/api/mcp/prompts.js +281 -281
  48. package/lib/api/mcp/resources.js +300 -241
  49. package/lib/api/mcp/sandbox.js +89 -0
  50. package/lib/api/mcp/server.js +675 -312
  51. package/lib/api/mcp/start.js +125 -0
  52. package/lib/api/mcp/transports/HttpSseTransport.js +60 -52
  53. package/lib/api/rest/server.js +105 -28
  54. package/lib/cache/CacheManager.js +9 -17
  55. package/lib/core/Analyzer.js +16 -10
  56. package/lib/core/ContextBuilder.js +19 -28
  57. package/lib/core/Reporter.js +15 -14
  58. package/lib/core/Scanner.js +81 -12
  59. package/lib/count/index.js +100 -0
  60. package/lib/formatters/format-registry.js +394 -372
  61. package/lib/formatters/gitingest-formatter.js +656 -621
  62. package/lib/formatters/toon-formatter-v1.3.js +637 -632
  63. package/lib/formatters/toon-formatter.js +239 -240
  64. package/lib/integrations/git/BlameTracker.js +4 -4
  65. package/lib/integrations/git/DiffAnalyzer.js +38 -31
  66. package/lib/integrations/git/GitClient.js +71 -53
  67. package/lib/lsp/server.js +311 -0
  68. package/lib/map/graph.js +299 -0
  69. package/lib/map/imports.js +233 -0
  70. package/lib/map/index.js +33 -0
  71. package/lib/map/rank.js +253 -0
  72. package/lib/map/render.js +346 -0
  73. package/lib/map/resolver.js +574 -0
  74. package/lib/pack/budget.js +111 -0
  75. package/lib/pack/code-lexer.js +360 -0
  76. package/lib/pack/compress.js +414 -0
  77. package/lib/pack/index.js +354 -0
  78. package/lib/pack/renderers/common.js +134 -0
  79. package/lib/pack/renderers/index.js +24 -0
  80. package/lib/pack/renderers/json.js +47 -0
  81. package/lib/pack/renderers/markdown.js +54 -0
  82. package/lib/pack/renderers/plain.js +38 -0
  83. package/lib/pack/renderers/xml.js +85 -0
  84. package/lib/pack/select.js +177 -0
  85. package/lib/pack/split.js +182 -0
  86. package/lib/pack/tree.js +35 -0
  87. package/lib/parsers/gitignore-parser.js +113 -107
  88. package/lib/parsers/method-filter-parser.js +50 -33
  89. package/lib/plugins/ExporterPlugin.js +3 -3
  90. package/lib/plugins/LanguagePlugin.js +3 -3
  91. package/lib/plugins/PluginManager.js +7 -13
  92. package/lib/policy/audit.js +294 -0
  93. package/lib/policy/enforce.js +125 -0
  94. package/lib/policy/ignore-files.js +169 -0
  95. package/lib/policy/index.js +52 -0
  96. package/lib/policy/policy.js +489 -0
  97. package/lib/pr/diff-parser.js +267 -0
  98. package/lib/pr/method-mapper.js +148 -0
  99. package/lib/pr/pr-context.js +829 -0
  100. package/lib/pr/pr-git.js +149 -0
  101. package/lib/pr/pr-renderer.js +644 -0
  102. package/lib/pr/related-tests.js +296 -0
  103. package/lib/rag/DataSourcePlugin.js +5 -5
  104. package/lib/rag/EmbeddingProvider.js +19 -8
  105. package/lib/rag/Indexer.js +14 -5
  106. package/lib/rag/VectorStoreFactory.js +3 -3
  107. package/lib/rag/sources/FileDataSource.js +6 -6
  108. package/lib/rag/stores/BaseVectorStore.js +5 -5
  109. package/lib/rag/stores/ChromaDBStore.js +10 -10
  110. package/lib/rag/stores/LanceDBStore.js +29 -19
  111. package/lib/rag/stores/PineconeStore.js +11 -9
  112. package/lib/search/bm25.js +433 -0
  113. package/lib/search/code-units.js +201 -0
  114. package/lib/search/symbols.js +189 -0
  115. package/lib/ui/dashboard.js +120 -89
  116. package/lib/ui/index.js +1 -6
  117. package/lib/ui/init-wizard-ui.js +550 -0
  118. package/lib/ui/progress-bar.js +94 -88
  119. package/lib/ui/select-input.js +40 -32
  120. package/lib/ui/template-selector.js +99 -0
  121. package/lib/ui/wizard.js +374 -329
  122. package/lib/utils/claude-token-counter.js +151 -0
  123. package/lib/utils/cli-output.js +17 -0
  124. package/lib/utils/clipboard-utils.js +49 -50
  125. package/lib/utils/config-utils.js +81 -66
  126. package/lib/utils/context-versioning.js +532 -0
  127. package/lib/utils/error-handler.js +112 -112
  128. package/lib/utils/file-utils.js +153 -69
  129. package/lib/utils/format-converter.js +356 -283
  130. package/lib/utils/git-utils.js +366 -294
  131. package/lib/utils/llm-cost-estimator.js +281 -0
  132. package/lib/utils/llm-detector.js +242 -78
  133. package/lib/utils/logger.js +243 -237
  134. package/lib/utils/messages.js +355 -0
  135. package/lib/utils/multi-repo-manager.js +364 -0
  136. package/lib/utils/profile-manager.js +380 -0
  137. package/lib/utils/safe-read.js +61 -0
  138. package/lib/utils/secret-redactor.js +61 -0
  139. package/lib/utils/snapshot-manager.js +679 -0
  140. package/lib/utils/template-manager.js +378 -0
  141. package/lib/utils/token-utils.js +201 -170
  142. package/lib/utils/tokenizer-adapter.js +421 -377
  143. package/lib/utils/updater.js +539 -506
  144. package/lib/watch/FileWatcher.js +12 -8
  145. package/lib/watch/IncrementalAnalyzer.js +7 -7
  146. package/lib/why/coupling.js +81 -0
  147. package/lib/why/errors.js +23 -0
  148. package/lib/why/git.js +169 -0
  149. package/lib/why/history.js +108 -0
  150. package/lib/why/index.js +720 -0
  151. package/lib/why/ownership.js +79 -0
  152. package/lib/why/paths.js +207 -0
  153. package/lib/why/references.js +259 -0
  154. package/lib/why/render.js +722 -0
  155. package/lib/why/search.js +358 -0
  156. package/lib/why/target.js +389 -0
  157. package/lib/wizards/config-generator.js +255 -0
  158. package/lib/wizards/project-detector.js +571 -0
  159. package/package.json +44 -10
  160. package/.ctxman/logs/ctxman-2026-07-02.log +0 -156
  161. package/.ctxman/self-knowledge/documents.lance/_transactions/0-b3dd1558-f95b-44b9-a154-18e8c330ae7a.txn +0 -0
  162. package/.ctxman/self-knowledge/documents.lance/_transactions/1-44bff6d4-e552-4010-a769-e09ad822698d.txn +0 -0
  163. package/.ctxman/self-knowledge/documents.lance/_transactions/10-37260794-5947-4ecb-b4c8-d732f58f40f1.txn +0 -0
  164. package/.ctxman/self-knowledge/documents.lance/_transactions/11-0d52a883-5518-463c-9b6d-986c3af89dfd.txn +0 -0
  165. package/.ctxman/self-knowledge/documents.lance/_transactions/12-ae354646-52f2-49c9-90fb-2e6ed8932663.txn +0 -0
  166. package/.ctxman/self-knowledge/documents.lance/_transactions/13-5d7d5e57-27da-498b-b734-091b9d38da13.txn +0 -0
  167. package/.ctxman/self-knowledge/documents.lance/_transactions/14-558ce928-810b-4252-a364-fdd1ee6f8a25.txn +0 -0
  168. package/.ctxman/self-knowledge/documents.lance/_transactions/15-27e3a58f-8b64-43b8-a9f4-e3369446968b.txn +0 -0
  169. package/.ctxman/self-knowledge/documents.lance/_transactions/16-a110c390-e5c8-4464-b976-bc7f5195fa15.txn +0 -0
  170. package/.ctxman/self-knowledge/documents.lance/_transactions/17-97513225-53de-4216-b61a-6337bd0cc4ad.txn +0 -0
  171. package/.ctxman/self-knowledge/documents.lance/_transactions/18-5ba450f0-c527-459c-bf4f-6d79b5e4285f.txn +0 -0
  172. package/.ctxman/self-knowledge/documents.lance/_transactions/19-600d3a63-5225-49e9-b8b7-30e037b564ab.txn +0 -0
  173. package/.ctxman/self-knowledge/documents.lance/_transactions/2-48a61b4e-92cf-4342-8376-914c1b99a9f9.txn +0 -0
  174. package/.ctxman/self-knowledge/documents.lance/_transactions/20-121dd912-5410-410e-84ff-82c7a23604a0.txn +0 -0
  175. package/.ctxman/self-knowledge/documents.lance/_transactions/3-bc6844ab-7ea3-4bb3-9ee1-538ce5664a9c.txn +0 -0
  176. package/.ctxman/self-knowledge/documents.lance/_transactions/4-d5e1754f-dbb5-454e-a9be-2a7da8d7bcf9.txn +0 -0
  177. package/.ctxman/self-knowledge/documents.lance/_transactions/5-4fae93df-0568-4b6f-8acf-8bef4bba6047.txn +0 -0
  178. package/.ctxman/self-knowledge/documents.lance/_transactions/6-4b1b8b87-068e-49a3-ba2b-d8dae628b252.txn +0 -0
  179. package/.ctxman/self-knowledge/documents.lance/_transactions/7-63d040fb-d2fa-4c0f-bed0-53411918a7b0.txn +0 -0
  180. package/.ctxman/self-knowledge/documents.lance/_transactions/8-c5628cf6-264c-420f-aab8-bc96a9083591.txn +0 -0
  181. package/.ctxman/self-knowledge/documents.lance/_transactions/9-c94570aa-5e53-4fcb-971f-cecc1d1d5492.txn +0 -0
  182. package/.ctxman/self-knowledge/documents.lance/_versions/1.manifest +0 -0
  183. package/.ctxman/self-knowledge/documents.lance/_versions/10.manifest +0 -0
  184. package/.ctxman/self-knowledge/documents.lance/_versions/11.manifest +0 -0
  185. package/.ctxman/self-knowledge/documents.lance/_versions/12.manifest +0 -0
  186. package/.ctxman/self-knowledge/documents.lance/_versions/13.manifest +0 -0
  187. package/.ctxman/self-knowledge/documents.lance/_versions/14.manifest +0 -0
  188. package/.ctxman/self-knowledge/documents.lance/_versions/15.manifest +0 -0
  189. package/.ctxman/self-knowledge/documents.lance/_versions/16.manifest +0 -0
  190. package/.ctxman/self-knowledge/documents.lance/_versions/17.manifest +0 -0
  191. package/.ctxman/self-knowledge/documents.lance/_versions/18.manifest +0 -0
  192. package/.ctxman/self-knowledge/documents.lance/_versions/19.manifest +0 -0
  193. package/.ctxman/self-knowledge/documents.lance/_versions/2.manifest +0 -0
  194. package/.ctxman/self-knowledge/documents.lance/_versions/20.manifest +0 -0
  195. package/.ctxman/self-knowledge/documents.lance/_versions/21.manifest +0 -0
  196. package/.ctxman/self-knowledge/documents.lance/_versions/3.manifest +0 -0
  197. package/.ctxman/self-knowledge/documents.lance/_versions/4.manifest +0 -0
  198. package/.ctxman/self-knowledge/documents.lance/_versions/5.manifest +0 -0
  199. package/.ctxman/self-knowledge/documents.lance/_versions/6.manifest +0 -0
  200. package/.ctxman/self-knowledge/documents.lance/_versions/7.manifest +0 -0
  201. package/.ctxman/self-knowledge/documents.lance/_versions/8.manifest +0 -0
  202. package/.ctxman/self-knowledge/documents.lance/_versions/9.manifest +0 -0
  203. package/.ctxman/self-knowledge/documents.lance/data/000010010011011101000010de8621405d9d5cb06540dbf02c.lance +0 -0
  204. package/.ctxman/self-knowledge/documents.lance/data/0000101001011100011000003fb62a4aa8978974075c144f1c.lance +0 -0
  205. package/.ctxman/self-knowledge/documents.lance/data/000011010000100111001011c6cc824d628f3bb1f4e04cd5d0.lance +0 -0
  206. package/.ctxman/self-knowledge/documents.lance/data/001000001000011001011100d8f74349ada090b332cd1df669.lance +0 -0
  207. package/.ctxman/self-knowledge/documents.lance/data/01000001111010001000010045ab44467bb1e7e4d8ae06bd58.lance +0 -0
  208. package/.ctxman/self-knowledge/documents.lance/data/01000101101110111110011131ef904241a89e1ffb6ddaf8e3.lance +0 -0
  209. package/.ctxman/self-knowledge/documents.lance/data/010010110000010000011010fbfcc145c6a8a827b28057a07e.lance +0 -0
  210. package/.ctxman/self-knowledge/documents.lance/data/1000011000101010001110003fb4f045c3835982efa1405324.lance +0 -0
  211. package/.ctxman/self-knowledge/documents.lance/data/100111011101110111101011f4396b410fa06af3b028d204e6.lance +0 -0
  212. package/.ctxman/self-knowledge/documents.lance/data/100111100111101000001100596b174e00995d6cc2969cc8dc.lance +0 -0
  213. package/.ctxman/self-knowledge/documents.lance/data/1010001100011000110010102ea16b484d887c7c9725feffd6.lance +0 -0
  214. package/.ctxman/self-knowledge/documents.lance/data/101101100010101111110111c5230944d9b3af7301b6392dda.lance +0 -0
  215. package/.ctxman/self-knowledge/documents.lance/data/1011011100100111111110105892bf41b5a530fedd7cf516f1.lance +0 -0
  216. package/.ctxman/self-knowledge/documents.lance/data/10111110000010001011110164e8774742bade87b483c10f36.lance +0 -0
  217. package/.ctxman/self-knowledge/documents.lance/data/1100000100010110001110103ffaca40a2a42c85067ebaf9ae.lance +0 -0
  218. package/.ctxman/self-knowledge/documents.lance/data/110000010001110110101110664bde4c76a78dfc14b05e83b8.lance +0 -0
  219. package/.ctxman/self-knowledge/documents.lance/data/11001100111000010100110050645c4281b1f35b2022234f7f.lance +0 -0
  220. package/.ctxman/self-knowledge/documents.lance/data/1101111011001100110111017214924a368f5719eab2246619.lance +0 -0
  221. package/.ctxman/self-knowledge/documents.lance/data/1110000111001010001000002150c24dcab653d39d07032e63.lance +0 -0
  222. package/.ctxman/self-knowledge/documents.lance/data/1110100000000100000011014d20534967a18b5c87721659ff.lance +0 -0
  223. package/.ctxman/self-knowledge/documents.lance/data/11110101111010000101011135a0114ef2b35dc6f1297a3381.lance +0 -0
package/bin/cm-pack.js ADDED
@@ -0,0 +1,405 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * ctxman pack - the contents of a project's files in one LLM-ready document
4
+ *
5
+ * stdout carries only the document (unless -o/--copy); the summary, notices and errors go
6
+ * to stderr. Exit codes: 0 done, 1 error, 2 the document is larger than --max-tokens.
7
+ */
8
+
9
+ import fs from 'fs';
10
+ import path from 'path';
11
+ import { getLogger } from '../lib/utils/logger.js';
12
+
13
+ const STYLES = ['xml', 'markdown', 'plain', 'json'];
14
+ // Exit code when the document is larger than --max-tokens
15
+ const EXIT_TOO_LARGE = 2;
16
+
17
+ function printHelp() {
18
+ process.stdout.write(`ctxman pack - file contents in one LLM-ready document
19
+
20
+ Usage: ctxman pack [path] [options]
21
+
22
+ Packs the files of a project (default: the current directory) into one document: a
23
+ directory tree, then every file, then your instructions last. .gitignore, .contextignore
24
+ and .contextinclude decide which files are included; binary files and files over 1 MiB
25
+ are skipped. Secrets (API keys, tokens, private keys) are masked.
26
+
27
+ Selection:
28
+ --include GLOB Only files matching GLOB (repeatable or comma-separated)
29
+ --exclude GLOB Leave out files matching GLOB (repeatable or comma-separated)
30
+ --stdin Read the file list from stdin, one path per line, instead of
31
+ scanning; paths must lie inside the project, ignore files apply
32
+ -0, --null With --stdin: paths are separated by NUL bytes
33
+ (find -print0, git ls-files -z)
34
+ --max-file-size SIZE Skip larger files (default 1m; bytes, or with a k/m suffix)
35
+
36
+ Document:
37
+ --style STYLE xml (default; Anthropic's <documents> format), markdown, plain, json
38
+ --json Same as --style json
39
+ --no-tree Leave out the directory tree
40
+ --header TEXT Text at the top
41
+ --instructions FILE Append the text of FILE at the end, after the files
42
+ --line-numbers Number every line (original numbers, also with --compress)
43
+ --compress Keep signatures, replace function bodies with a one-line
44
+ placeholder (languages of the method analyzers)
45
+ --no-redact Do not mask secrets
46
+
47
+ Size:
48
+ --budget TOKENS Keep the document within TOKENS (e.g. 50000 or 50k): the
49
+ highest-ranked files first (ranking of ctxman map), the files
50
+ left out are listed with their token counts
51
+ --max-tokens TOKENS Exit with code 2, writing nothing, when the document is larger
52
+ --split TOKENS With -o: write numbered parts of at most TOKENS each
53
+ (FILE.1.xml, FILE.2.xml, ...)
54
+ --target-model ID Count with this model's tokenizer encoding and report how the
55
+ document fits its context window (see ctxman --list-llms)
56
+
57
+ Output:
58
+ -o, --output FILE Write to FILE instead of stdout
59
+ --copy Copy to the clipboard instead of stdout
60
+ -q, --quiet No summary on stderr
61
+ -h, --help Show this help
62
+
63
+ Examples:
64
+ ctxman pack -o context.xml
65
+ ctxman pack src --include "**/*.ts" --exclude "**/*.test.ts" --style markdown
66
+ ctxman pack --compress --budget 100k --instructions task.md --copy
67
+ git ls-files -z -- '*.py' | ctxman pack --stdin -0 --line-numbers
68
+ ctxman pack --target-model gpt-4o --max-tokens 120k -q -o context.xml
69
+ ctxman pack --split 150k -o context.xml # context.1.xml, context.2.xml, ...
70
+ `);
71
+ }
72
+
73
+ /** "8000", "8k", "1.5k" or "2m" → 8000 / 1500 / 2000000 */
74
+ function parseTokens(text, flag) {
75
+ const match = /^(\d+(?:\.\d+)?)([km])?$/i.exec(String(text).trim());
76
+ const scale = { k: 1e3, m: 1e6 }[match?.[2]?.toLowerCase()] ?? 1;
77
+ const value = match ? Math.round(Number(match[1]) * scale) : NaN;
78
+ if (!Number.isInteger(value) || value <= 0) {
79
+ throw new Error(`${flag} needs a positive number of tokens (e.g. 50000 or 50k), got ${text}`);
80
+ }
81
+ return value;
82
+ }
83
+
84
+ /** "4096", "512k" or "2m" (KiB/MiB) → bytes */
85
+ function parseSize(text, flag) {
86
+ const match = /^(\d+)([km])?$/i.exec(String(text).trim());
87
+ const scale = { k: 1024, m: 1024 * 1024 }[match?.[2]?.toLowerCase()] ?? 1;
88
+ const value = match ? Number(match[1]) * scale : NaN;
89
+ if (!Number.isInteger(value) || value <= 0) {
90
+ throw new Error(`${flag} needs a size in bytes (e.g. 500000, 512k or 2m), got ${text}`);
91
+ }
92
+ return value;
93
+ }
94
+
95
+ const VALUE_OPTIONS = {
96
+ '--include': 'include',
97
+ '--exclude': 'exclude',
98
+ '--max-file-size': 'maxFileSize',
99
+ '--style': 'style',
100
+ '--header': 'header',
101
+ '--instructions': 'instructions',
102
+ '--budget': 'budget',
103
+ '--max-tokens': 'maxTokens',
104
+ '--split': 'split',
105
+ '--target-model': 'targetModel',
106
+ '--output': 'output',
107
+ '-o': 'output',
108
+ };
109
+
110
+ const FLAG_OPTIONS = {
111
+ '--stdin': ['stdin', true],
112
+ '--null': ['nul', true],
113
+ '-0': ['nul', true],
114
+ '--json': ['style', 'json'],
115
+ '--tree': ['tree', true],
116
+ '--no-tree': ['tree', false],
117
+ '--line-numbers': ['lineNumbers', true],
118
+ '--compress': ['compress', true],
119
+ '--redact': ['redact', true],
120
+ '--no-redact': ['redact', false],
121
+ '--copy': ['copy', true],
122
+ '--quiet': ['quiet', true],
123
+ '-q': ['quiet', true],
124
+ '--help': ['help', true],
125
+ '-h': ['help', true],
126
+ };
127
+
128
+ /**
129
+ * Parse the command line
130
+ * @param {string[]} argv
131
+ * @returns {object}
132
+ */
133
+ function parseArgs(argv) {
134
+ const options = { include: [], exclude: [], tree: true, redact: true, paths: [] };
135
+ for (let i = 0; i < argv.length; i++) {
136
+ const arg = argv[i];
137
+ if (arg === '--') {
138
+ options.paths.push(...argv.slice(i + 1));
139
+ break;
140
+ }
141
+ const eq = arg.startsWith('--') ? arg.indexOf('=') : -1;
142
+ const name = eq > 0 ? arg.slice(0, eq) : arg;
143
+ if (VALUE_OPTIONS[name]) {
144
+ let value;
145
+ if (eq > 0) value = arg.slice(eq + 1);
146
+ else if (i + 1 < argv.length) value = argv[++i];
147
+ else throw new Error(`${name} needs a value`);
148
+ const key = VALUE_OPTIONS[name];
149
+ if (key === 'include' || key === 'exclude') {
150
+ options[key].push(...value.split(',').filter((glob) => glob.trim()));
151
+ } else {
152
+ options[key] = value;
153
+ }
154
+ } else if (FLAG_OPTIONS[name] && eq < 0) {
155
+ const [key, value] = FLAG_OPTIONS[name];
156
+ options[key] = value;
157
+ } else if (arg.startsWith('-') && arg !== '-') {
158
+ throw new Error(`Unknown option: ${arg}`);
159
+ } else {
160
+ options.paths.push(arg);
161
+ }
162
+ }
163
+
164
+ if (options.paths.length > 1) {
165
+ throw new Error(`Expected one project path, got ${options.paths.length}`);
166
+ }
167
+ if (options.style !== undefined && !STYLES.includes(options.style)) {
168
+ throw new Error(`--style must be one of ${STYLES.join(', ')}`);
169
+ }
170
+ for (const [key, flag] of [
171
+ ['budget', '--budget'],
172
+ ['maxTokens', '--max-tokens'],
173
+ ['split', '--split'],
174
+ ]) {
175
+ if (options[key] !== undefined) options[key] = parseTokens(options[key], flag);
176
+ }
177
+ if (options.maxFileSize !== undefined) {
178
+ options.maxFileSize = parseSize(options.maxFileSize, '--max-file-size');
179
+ }
180
+ if (options.nul && !options.stdin) throw new Error('-0/--null only applies to --stdin');
181
+ if (options.split && !options.output) {
182
+ throw new Error('--split writes numbered files: give the first name with -o/--output');
183
+ }
184
+ if (options.split && options.copy) throw new Error('--split cannot be combined with --copy');
185
+ return options;
186
+ }
187
+
188
+ /** Paths of the parts of a split output: context.xml → context.1.xml, context.2.xml, ... */
189
+ function partPath(output, index, count) {
190
+ const extension = path.extname(output);
191
+ const base = output.slice(0, output.length - extension.length);
192
+ return `${base}.${String(index).padStart(String(count).length, '0')}${extension}`;
193
+ }
194
+
195
+ /** Existing files that look like parts of `output` (left out of the pack, like the output) */
196
+ function existingParts(output) {
197
+ const directory = path.dirname(output);
198
+ const extension = path.extname(output);
199
+ const base = path.basename(output, extension);
200
+ const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
201
+ const pattern = new RegExp(`^${escape(base)}\\.\\d+${escape(extension)}$`);
202
+ try {
203
+ return fs
204
+ .readdirSync(directory)
205
+ .filter((name) => pattern.test(name))
206
+ .map((name) => path.join(directory, name));
207
+ } catch (_error) {
208
+ return [];
209
+ }
210
+ }
211
+
212
+ const formatNumber = (n) => Number(n).toLocaleString('en-US');
213
+
214
+ /** A path as the user would type it: relative to the current directory when below it */
215
+ function display(file) {
216
+ const relative = path.relative(process.cwd(), file);
217
+ return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file;
218
+ }
219
+
220
+ function printSummary(result, extras) {
221
+ const lines = [];
222
+ const style = result.style;
223
+ lines.push(
224
+ `ctxman pack: ${formatNumber(result.files.length)} file(s), ` +
225
+ `${formatNumber(result.totalTokens)} tokens (${style}, ${result.encoding})`
226
+ );
227
+ if (result.budget) {
228
+ const omitted = result.omitted.length;
229
+ const omittedTokens = result.omitted.reduce((sum, file) => sum + file.tokens, 0);
230
+ lines.push(
231
+ ` Budget ${formatNumber(result.budget.limit)}: ${formatNumber(result.budget.used)} used` +
232
+ (omitted
233
+ ? `; ${formatNumber(omitted)} file(s) left out (${formatNumber(omittedTokens)} tokens)`
234
+ : '; every file fits')
235
+ );
236
+ }
237
+ if (result.compression) {
238
+ const { files, bodies, originalTokens, savedTokens } = result.compression;
239
+ const percent = originalTokens ? Math.round((savedTokens / originalTokens) * 100) : 0;
240
+ lines.push(
241
+ ` Compressed ${formatNumber(bodies)} function ${bodies === 1 ? 'body' : 'bodies'} in ` +
242
+ `${formatNumber(files)} file(s): ` +
243
+ `${formatNumber(savedTokens)} tokens saved (${percent}%)`
244
+ );
245
+ }
246
+ if (result.redactions.total) {
247
+ const types = Object.entries(result.redactions.byType)
248
+ .map(([type, count]) => `${count} ${type}`)
249
+ .join(', ');
250
+ lines.push(` Redacted ${formatNumber(result.redactions.total)} secret(s): ${types}`);
251
+ }
252
+ if (result.skipped.length) {
253
+ const reasons = {};
254
+ for (const file of result.skipped) reasons[file.reason] = (reasons[file.reason] ?? 0) + 1;
255
+ const text = Object.entries(reasons)
256
+ .map(([reason, count]) => `${count} ${reason}`)
257
+ .join(', ');
258
+ lines.push(` Skipped ${formatNumber(result.skipped.length)} file(s): ${text}`);
259
+ }
260
+ if (!result.files.length && !result.omitted.length) {
261
+ lines.push(
262
+ ' Warning: no files to pack (check the path, --include/--exclude and the ignore files)'
263
+ );
264
+ }
265
+ for (const file of result.splitFiles) {
266
+ lines.push(` ${file.path} alone exceeds a part: split by lines into ${file.pieces} pieces`);
267
+ }
268
+ if (result.model) {
269
+ const model = result.model;
270
+ const how = model.exact ? 'exact' : 'approximate';
271
+ lines.push(
272
+ ` ${model.name} (${model.id}): ${model.percentOfWindow.toFixed(1)}% of the ` +
273
+ `${formatNumber(model.contextWindow)}-token window, ` +
274
+ `${model.fits ? 'fits' : "doesn't fit"} (${how} count, ${model.encoding})`
275
+ );
276
+ if (model.fits && model.maxRecommendedInput && result.totalTokens > model.maxRecommendedInput) {
277
+ lines.push(
278
+ ` Note: above the recommended ${formatNumber(model.maxRecommendedInput)} input tokens ` +
279
+ 'for this model; consider --budget or --compress'
280
+ );
281
+ }
282
+ }
283
+ lines.push(...extras.map((line) => ` ${line}`));
284
+ process.stderr.write(`${lines.join('\n')}\n`);
285
+ }
286
+
287
+ /** The largest packed files, to say what to leave out when --max-tokens is exceeded */
288
+ function largestFiles(result, count = 5) {
289
+ return [...result.files]
290
+ .sort((a, b) => b.tokens - a.tokens)
291
+ .slice(0, count)
292
+ .map((file) => ` ${formatNumber(file.tokens).padStart(9)} ${file.path}`);
293
+ }
294
+
295
+ async function main() {
296
+ let args;
297
+ try {
298
+ args = parseArgs(process.argv.slice(2));
299
+ } catch (error) {
300
+ process.stderr.write(`Error: ${error.message}\nRun "ctxman pack --help" for usage.\n`);
301
+ process.exitCode = 1;
302
+ return;
303
+ }
304
+ if (args.help) {
305
+ printHelp();
306
+ return;
307
+ }
308
+
309
+ // stdout carries only the document: logger lines and ignore-file notices (console.log in
310
+ // shared modules) go to stderr, and pack writes no log files into the project
311
+ getLogger({ level: process.env.LOG_LEVEL || 'warn', logToFile: false });
312
+ console.log = console.info = console.debug = console.error;
313
+
314
+ try {
315
+ const { packContext } = await import('../lib/pack/index.js');
316
+ const root = path.resolve(args.paths[0] ?? '.');
317
+ const output = args.output ? path.resolve(args.output) : null;
318
+
319
+ const options = {
320
+ style: args.style,
321
+ include: args.include,
322
+ exclude: args.exclude,
323
+ maxFileSize: args.maxFileSize,
324
+ tree: args.tree,
325
+ header: args.header,
326
+ lineNumbers: args.lineNumbers,
327
+ compress: args.compress,
328
+ redact: args.redact,
329
+ budget: args.budget,
330
+ split: args.split,
331
+ targetModel: args.targetModel,
332
+ // A previous output inside the project is not packed again
333
+ excludeFiles: output ? [output, ...(args.split ? existingParts(output) : [])] : [],
334
+ };
335
+ if (args.instructions !== undefined) {
336
+ options.instructions = fs.readFileSync(path.resolve(args.instructions), 'utf8');
337
+ }
338
+ if (args.stdin) {
339
+ const input = fs.readFileSync(0, 'utf8');
340
+ options.files = input
341
+ .split(args.nul ? '\0' : '\n')
342
+ .map((line) => (args.nul ? line : line.replace(/\r$/, '')))
343
+ .filter((line) => line.trim() !== '');
344
+ options.cwd = process.cwd();
345
+ }
346
+
347
+ const result = await packContext(root, options);
348
+ const extras = [];
349
+
350
+ if (args.maxTokens && result.totalTokens > args.maxTokens) {
351
+ if (!args.quiet) printSummary(result, extras);
352
+ process.stderr.write(
353
+ `Error: the document has ${formatNumber(result.totalTokens)} tokens, more than ` +
354
+ `--max-tokens ${formatNumber(args.maxTokens)}; nothing was written. Largest files:\n` +
355
+ `${largestFiles(result).join('\n')}\n`
356
+ );
357
+ process.exitCode = EXIT_TOO_LARGE;
358
+ return;
359
+ }
360
+
361
+ let toStdout = !output && !args.copy;
362
+ if (output && result.parts) {
363
+ fs.mkdirSync(path.dirname(output), { recursive: true });
364
+ const written = new Set();
365
+ for (const part of result.parts) {
366
+ const file = partPath(output, part.index, result.parts.length);
367
+ fs.writeFileSync(file, part.text);
368
+ written.add(file);
369
+ extras.push(`Part ${part.index}: ${display(file)} (${formatNumber(part.tokens)} tokens)`);
370
+ }
371
+ // Parts of an earlier, longer run are left alone, but they are not part of this output
372
+ const stale = existingParts(output).filter((file) => !written.has(file));
373
+ if (stale.length) {
374
+ extras.push(
375
+ `Note: ${stale.map(display).join(', ')} ${stale.length === 1 ? 'is' : 'are'} ` +
376
+ 'from an earlier run, not part of this output'
377
+ );
378
+ }
379
+ } else if (output) {
380
+ fs.mkdirSync(path.dirname(output), { recursive: true });
381
+ fs.writeFileSync(output, result.text);
382
+ extras.push(`Written to ${display(output)}`);
383
+ }
384
+ if (args.copy) {
385
+ const { default: ClipboardUtils } = await import('../lib/utils/clipboard-utils.js');
386
+ if (!ClipboardUtils.copy(result.text) && !output) {
387
+ extras.push('Clipboard unavailable; writing to stdout instead');
388
+ toStdout = true;
389
+ }
390
+ }
391
+ if (toStdout) process.stdout.write(result.text);
392
+ if (!args.quiet) printSummary(result, extras);
393
+ } catch (error) {
394
+ process.stderr.write(`Error: ${error.message}\n`);
395
+ process.exitCode = 1;
396
+ }
397
+ }
398
+
399
+ // `ctxman pack | head` closes the pipe early: stop quietly instead of crashing on EPIPE
400
+ process.stdout.on('error', (error) => {
401
+ if (error.code !== 'EPIPE') throw error;
402
+ process.exit(process.exitCode ?? 0);
403
+ });
404
+
405
+ main();
@@ -0,0 +1,170 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Policy Command
4
+ * Validate and show the export policy (`ctxman policy check`), or write a starter policy
5
+ * (`ctxman policy init`)
6
+ */
7
+
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ import {
11
+ loadPolicy,
12
+ createStarterPolicy,
13
+ loadIgnoreFileRules,
14
+ POLICY_FILE,
15
+ POLICY_ENV,
16
+ POLICY_DOCS_URL,
17
+ PolicyConfigError,
18
+ } from '../lib/policy/index.js';
19
+
20
+ function main() {
21
+ const args = process.argv.slice(2);
22
+
23
+ if (args.includes('--help') || args.includes('-h')) {
24
+ printHelp();
25
+ return;
26
+ }
27
+
28
+ const command = args[0] && !args[0].startsWith('-') ? args[0] : 'check';
29
+
30
+ switch (command) {
31
+ case 'check':
32
+ check(args.includes('--json'));
33
+ break;
34
+ case 'init':
35
+ init(args.includes('--force') || args.includes('-f'));
36
+ break;
37
+ default:
38
+ console.error(`❌ Unknown policy command: ${command}`);
39
+ console.error(' Usage: ctxman policy <check|init> (see ctxman policy --help)');
40
+ process.exitCode = 2;
41
+ }
42
+ }
43
+
44
+ function check(json) {
45
+ const root = process.cwd();
46
+ let policy;
47
+ try {
48
+ policy = loadPolicy(root);
49
+ } catch (error) {
50
+ if (!(error instanceof PolicyConfigError)) throw error;
51
+ if (json) {
52
+ console.log(
53
+ JSON.stringify({ valid: false, file: error.file, errors: error.errors }, null, 2)
54
+ );
55
+ } else {
56
+ console.error(`❌ ${error.message}`);
57
+ }
58
+ process.exitCode = error.exitCode;
59
+ return;
60
+ }
61
+
62
+ if (json) {
63
+ console.log(JSON.stringify({ valid: true, ...policy.toJSON() }, null, 2));
64
+ return;
65
+ }
66
+
67
+ if (!policy.active) {
68
+ console.log(`No export policy: there is no ${POLICY_FILE} and ${POLICY_ENV} is not set.`);
69
+ console.log('Exports are unrestricted. Create a policy with: ctxman policy init');
70
+ return;
71
+ }
72
+
73
+ const display = (file) => (file.startsWith(root + path.sep) ? path.relative(root, file) : file);
74
+ const label = (text) => `${text}:`.padEnd(24);
75
+
76
+ console.log('🛡️ ctxman export policy');
77
+ console.log(`Project root: ${root}`);
78
+ console.log();
79
+ console.log('Policy files:');
80
+ for (const { scope, file } of policy.sources) {
81
+ console.log(` ${scope.padEnd(13)} ${display(file)}`);
82
+ }
83
+ console.log();
84
+
85
+ console.log('Deny rules (never exported):');
86
+ if (policy.denyRules.length === 0) console.log(' (none)');
87
+ for (const rule of policy.denyRules) {
88
+ console.log(` [${rule.scope}] ${rule.patterns.join(' ')}`);
89
+ }
90
+ console.log(
91
+ label('Require redaction') + (policy.requireRedaction ? 'yes (--no-redact is refused)' : 'no')
92
+ );
93
+ console.log(
94
+ label('Max export tokens') +
95
+ (policy.maxExportTokens === null
96
+ ? 'no limit'
97
+ : policy.maxExportTokens.toLocaleString('en-US'))
98
+ );
99
+ console.log(
100
+ label('Allowed target models') +
101
+ (policy.modelRules.length === 0
102
+ ? 'any'
103
+ : policy.modelRules
104
+ .map((rule) => `${rule.patterns.join(', ')} [${rule.scope}]`)
105
+ .join(' AND '))
106
+ );
107
+
108
+ if (policy.ignoreFiles.length === 0) {
109
+ console.log(label('AI-tool ignore files') + 'none');
110
+ } else {
111
+ console.log(
112
+ label('AI-tool ignore files') + loadIgnoreFileRules(root, policy.ignoreFiles).describe()
113
+ );
114
+ }
115
+
116
+ console.log(
117
+ label('Audit log') + (policy.audit.enabled ? policy.audit.paths.map(display).join(', ') : 'off')
118
+ );
119
+ console.log();
120
+ console.log('✅ Policy is valid.');
121
+ }
122
+
123
+ function init(force) {
124
+ const file = path.join(process.cwd(), POLICY_FILE);
125
+
126
+ fs.mkdirSync(path.dirname(file), { recursive: true });
127
+ try {
128
+ // 'wx' fails when the file exists, without a separate check that could go stale
129
+ fs.writeFileSync(file, `${JSON.stringify(createStarterPolicy(), null, 2)}\n`, {
130
+ flag: force ? 'w' : 'wx',
131
+ });
132
+ } catch (error) {
133
+ if (error.code !== 'EEXIST') throw error;
134
+ console.error(`❌ ${POLICY_FILE} already exists. Use --force to overwrite it.`);
135
+ process.exitCode = 1;
136
+ return;
137
+ }
138
+
139
+ console.log(`✅ Wrote ${POLICY_FILE}`);
140
+ console.log();
141
+ console.log('JSON has no comments, so the "$comment" field points to the reference:');
142
+ console.log(` ${POLICY_DOCS_URL}`);
143
+ console.log();
144
+ console.log('Next steps:');
145
+ console.log(' 1. Edit the "deny" list (.gitignore syntax) and the limits.');
146
+ console.log(' 2. Run `ctxman policy check` to validate it.');
147
+ console.log(' 3. Add .ctxman/audit.log to .gitignore; commit .ctxman/policy.json.');
148
+ console.log(` Organisation-wide rules: point ${POLICY_ENV} at a shared policy file.`);
149
+ }
150
+
151
+ function printHelp() {
152
+ console.log('ctxman policy - export policy for this repository\n');
153
+ console.log('Usage: ctxman policy [check|init] [options]\n');
154
+ console.log('Commands:');
155
+ console.log(' check Validate the policy files and print the effective policy');
156
+ console.log(' (default)');
157
+ console.log(` init Write a starter ${POLICY_FILE}\n`);
158
+ console.log('Options:');
159
+ console.log(' --json check: print the effective policy as JSON');
160
+ console.log(' -f, --force init: overwrite an existing policy file');
161
+ console.log(' -h, --help Show this help\n');
162
+ console.log('Policy files:');
163
+ console.log(` ${POLICY_FILE} Project policy, committed with the repository`);
164
+ console.log(` $${POLICY_ENV} Organisation policy file; wins on conflicts (deny lists`);
165
+ console.log(' are combined, the stricter limit applies)\n');
166
+ console.log('Exit codes: 0 valid or no policy, 2 invalid policy file');
167
+ console.log(`Reference: ${POLICY_DOCS_URL}`);
168
+ }
169
+
170
+ main();