mdcontext 0.1.0 → 0.2.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 (251) hide show
  1. package/.changeset/config.json +9 -9
  2. package/.claude/settings.local.json +25 -0
  3. package/.github/workflows/claude-code-review.yml +44 -0
  4. package/.github/workflows/claude.yml +85 -0
  5. package/CONTRIBUTING.md +186 -0
  6. package/NOTES/NOTES +44 -0
  7. package/README.md +206 -3
  8. package/biome.json +1 -1
  9. package/dist/chunk-23UPXDNL.js +3044 -0
  10. package/dist/chunk-2W7MO2DL.js +1366 -0
  11. package/dist/chunk-3NUAZGMA.js +1689 -0
  12. package/dist/chunk-7TOWB2XB.js +366 -0
  13. package/dist/chunk-7XOTOADQ.js +3065 -0
  14. package/dist/chunk-AH2PDM2K.js +3042 -0
  15. package/dist/chunk-BNXWSZ63.js +3742 -0
  16. package/dist/chunk-BTL5DJVU.js +3222 -0
  17. package/dist/chunk-HDHYG7E4.js +104 -0
  18. package/dist/chunk-HLR4KZBP.js +3234 -0
  19. package/dist/chunk-IP3FRFEB.js +1045 -0
  20. package/dist/chunk-KHU56VDO.js +3042 -0
  21. package/dist/chunk-KRYIFLQR.js +85 -89
  22. package/dist/chunk-LBSDNLEM.js +287 -0
  23. package/dist/chunk-MNTQ7HCP.js +2643 -0
  24. package/dist/chunk-MUJELQQ6.js +1387 -0
  25. package/dist/chunk-MXJGMSLV.js +2199 -0
  26. package/dist/chunk-N6QJGC3Z.js +2636 -0
  27. package/dist/chunk-OBELGBPM.js +1713 -0
  28. package/dist/chunk-OT7R5XTA.js +3192 -0
  29. package/dist/chunk-P7X4RA2T.js +106 -0
  30. package/dist/chunk-PIDUQNC2.js +3185 -0
  31. package/dist/chunk-POGCDIH4.js +3187 -0
  32. package/dist/chunk-PSIEOQGZ.js +3043 -0
  33. package/dist/chunk-PVRT3IHA.js +3238 -0
  34. package/dist/chunk-QNN4TT23.js +1430 -0
  35. package/dist/chunk-RE3R45RJ.js +3042 -0
  36. package/dist/chunk-S7E6TFX6.js +718 -657
  37. package/dist/chunk-SG6GLU4U.js +1378 -0
  38. package/dist/chunk-SJCDV2ST.js +274 -0
  39. package/dist/chunk-SYE5XLF3.js +104 -0
  40. package/dist/chunk-T5VLYBZD.js +103 -0
  41. package/dist/chunk-TOQB7VWU.js +3238 -0
  42. package/dist/chunk-VFNMZ4ZQ.js +3228 -0
  43. package/dist/chunk-VVTGZNBT.js +1533 -1423
  44. package/dist/chunk-W7Q4RFEV.js +104 -0
  45. package/dist/chunk-XTYYVRLO.js +3190 -0
  46. package/dist/chunk-Y6MDYVJD.js +3063 -0
  47. package/dist/cli/main.js +4072 -629
  48. package/dist/index.d.ts +420 -33
  49. package/dist/index.js +8 -15
  50. package/dist/mcp/server.js +103 -7
  51. package/dist/schema-BAWSG7KY.js +22 -0
  52. package/dist/schema-E3QUPL26.js +20 -0
  53. package/dist/schema-EHL7WUT6.js +20 -0
  54. package/docs/019-USAGE.md +44 -5
  55. package/docs/020-current-implementation.md +8 -8
  56. package/docs/021-DOGFOODING-FINDINGS.md +1 -1
  57. package/docs/CONFIG.md +1123 -0
  58. package/docs/ERRORS.md +383 -0
  59. package/docs/summarization.md +320 -0
  60. package/justfile +40 -0
  61. package/package.json +39 -33
  62. package/research/INDEX.md +315 -0
  63. package/research/code-review/README.md +90 -0
  64. package/research/code-review/cli-error-handling-review.md +979 -0
  65. package/research/code-review/code-review-validation-report.md +464 -0
  66. package/research/code-review/main-ts-review.md +1128 -0
  67. package/research/config-docs/SUMMARY.md +357 -0
  68. package/research/config-docs/TEST-RESULTS.md +776 -0
  69. package/research/config-docs/TODO.md +542 -0
  70. package/research/config-docs/analysis.md +744 -0
  71. package/research/config-docs/fix-validation.md +502 -0
  72. package/research/config-docs/help-audit.md +264 -0
  73. package/research/config-docs/help-system-analysis.md +890 -0
  74. package/research/frontmatter/COMMENTS-ARE-SKIPPED.md +149 -0
  75. package/research/frontmatter/LLM-CODE-NAVIGATION.md +276 -0
  76. package/research/issue-review.md +603 -0
  77. package/research/llm-summarization/agent-cli-tools-2026.md +1082 -0
  78. package/research/llm-summarization/alternative-providers-2026.md +1428 -0
  79. package/research/llm-summarization/anthropic-2026.md +367 -0
  80. package/research/llm-summarization/claude-cli-integration.md +1706 -0
  81. package/research/llm-summarization/cli-integration-patterns.md +3155 -0
  82. package/research/llm-summarization/openai-2026.md +473 -0
  83. package/research/llm-summarization/openai-compatible-providers-2026.md +1022 -0
  84. package/research/llm-summarization/opencode-cli-integration.md +1552 -0
  85. package/research/llm-summarization/prompt-engineering-2026.md +1426 -0
  86. package/research/llm-summarization/prototype-results.md +56 -0
  87. package/research/llm-summarization/provider-switching-patterns-2026.md +2153 -0
  88. package/research/llm-summarization/typescript-llm-libraries-2026.md +2436 -0
  89. package/research/mdcontext-pudding/00-EXECUTIVE-SUMMARY.md +282 -0
  90. package/research/mdcontext-pudding/01-index-embed.md +956 -0
  91. package/research/mdcontext-pudding/02-search-COMMANDS.md +142 -0
  92. package/research/mdcontext-pudding/02-search-SUMMARY.md +146 -0
  93. package/research/mdcontext-pudding/02-search.md +970 -0
  94. package/research/mdcontext-pudding/03-context.md +779 -0
  95. package/research/mdcontext-pudding/04-navigation-and-analytics.md +803 -0
  96. package/research/mdcontext-pudding/04-tree.md +704 -0
  97. package/research/mdcontext-pudding/05-config.md +1038 -0
  98. package/research/mdcontext-pudding/06-links-summary.txt +87 -0
  99. package/research/mdcontext-pudding/06-links.md +679 -0
  100. package/research/mdcontext-pudding/07-stats.md +693 -0
  101. package/research/mdcontext-pudding/BUG-FIX-PLAN.md +388 -0
  102. package/research/mdcontext-pudding/P0-BUG-VALIDATION.md +167 -0
  103. package/research/mdcontext-pudding/README.md +168 -0
  104. package/research/mdcontext-pudding/TESTING-SUMMARY.md +128 -0
  105. package/research/research-quality-review.md +834 -0
  106. package/research/semantic-search/embedding-text-analysis.md +156 -0
  107. package/research/semantic-search/multi-word-failure-reproduction.md +171 -0
  108. package/research/semantic-search/query-processing-analysis.md +207 -0
  109. package/research/semantic-search/root-cause-and-solution.md +114 -0
  110. package/research/semantic-search/threshold-validation-report.md +69 -0
  111. package/research/semantic-search/vector-search-analysis.md +63 -0
  112. package/research/test-path-issues.md +276 -0
  113. package/review/ALP-76/1-error-type-design.md +962 -0
  114. package/review/ALP-76/2-error-handling-patterns.md +906 -0
  115. package/review/ALP-76/3-error-presentation.md +624 -0
  116. package/review/ALP-76/4-test-coverage.md +625 -0
  117. package/review/ALP-76/5-migration-completeness.md +440 -0
  118. package/review/ALP-76/6-effect-best-practices.md +755 -0
  119. package/scripts/apply-branch-protection.sh +47 -0
  120. package/scripts/branch-protection-templates.json +79 -0
  121. package/scripts/prototype-summarization.ts +346 -0
  122. package/scripts/rebuild-hnswlib.js +32 -37
  123. package/scripts/setup-branch-protection.sh +64 -0
  124. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/active-provider.json +7 -0
  125. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/bm25.json +541 -0
  126. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/bm25.meta.json +5 -0
  127. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/config.json +8 -0
  128. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/embeddings/openai_text-embedding-3-small_512/vectors.bin +0 -0
  129. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/embeddings/openai_text-embedding-3-small_512/vectors.meta.bin +0 -0
  130. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/indexes/documents.json +60 -0
  131. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/indexes/links.json +13 -0
  132. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/indexes/sections.json +1197 -0
  133. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/configuration-management.md +99 -0
  134. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/distributed-systems.md +92 -0
  135. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/error-handling.md +78 -0
  136. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/failure-automation.md +55 -0
  137. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/job-context.md +69 -0
  138. package/src/__tests__/fixtures/semantic-search/multi-word-corpus/process-orchestration.md +99 -0
  139. package/src/cli/argv-preprocessor.test.ts +2 -2
  140. package/src/cli/cli.test.ts +230 -33
  141. package/src/cli/commands/config-cmd.ts +642 -0
  142. package/src/cli/commands/context.ts +97 -9
  143. package/src/cli/commands/duplicates.ts +122 -0
  144. package/src/cli/commands/embeddings.ts +529 -0
  145. package/src/cli/commands/index-cmd.ts +210 -30
  146. package/src/cli/commands/index.ts +3 -0
  147. package/src/cli/commands/search.ts +894 -64
  148. package/src/cli/commands/stats.ts +3 -0
  149. package/src/cli/commands/tree.ts +26 -5
  150. package/src/cli/config-layer.ts +176 -0
  151. package/src/cli/error-handler.test.ts +235 -0
  152. package/src/cli/error-handler.ts +655 -0
  153. package/src/cli/flag-schemas.ts +66 -0
  154. package/src/cli/help.ts +209 -7
  155. package/src/cli/main.ts +348 -58
  156. package/src/cli/options.ts +10 -0
  157. package/src/cli/shared-error-handling.ts +199 -0
  158. package/src/cli/utils.ts +150 -17
  159. package/src/config/file-provider.test.ts +320 -0
  160. package/src/config/file-provider.ts +273 -0
  161. package/src/config/index.ts +72 -0
  162. package/src/config/integration.test.ts +667 -0
  163. package/src/config/precedence.test.ts +277 -0
  164. package/src/config/precedence.ts +451 -0
  165. package/src/config/schema.test.ts +414 -0
  166. package/src/config/schema.ts +603 -0
  167. package/src/config/service.test.ts +320 -0
  168. package/src/config/service.ts +243 -0
  169. package/src/config/testing.test.ts +264 -0
  170. package/src/config/testing.ts +110 -0
  171. package/src/core/types.ts +6 -33
  172. package/src/duplicates/detector.test.ts +183 -0
  173. package/src/duplicates/detector.ts +414 -0
  174. package/src/duplicates/index.ts +18 -0
  175. package/src/embeddings/embedding-namespace.test.ts +300 -0
  176. package/src/embeddings/embedding-namespace.ts +947 -0
  177. package/src/embeddings/heading-boost.test.ts +222 -0
  178. package/src/embeddings/hnsw-build-options.test.ts +198 -0
  179. package/src/embeddings/hyde.test.ts +272 -0
  180. package/src/embeddings/hyde.ts +264 -0
  181. package/src/embeddings/index.ts +2 -0
  182. package/src/embeddings/openai-provider.ts +332 -83
  183. package/src/embeddings/pricing.json +22 -0
  184. package/src/embeddings/provider-constants.ts +204 -0
  185. package/src/embeddings/provider-errors.test.ts +967 -0
  186. package/src/embeddings/provider-errors.ts +565 -0
  187. package/src/embeddings/provider-factory.test.ts +240 -0
  188. package/src/embeddings/provider-factory.ts +225 -0
  189. package/src/embeddings/provider-integration.test.ts +788 -0
  190. package/src/embeddings/query-preprocessing.test.ts +187 -0
  191. package/src/embeddings/semantic-search-threshold.test.ts +508 -0
  192. package/src/embeddings/semantic-search.ts +780 -93
  193. package/src/embeddings/types.ts +293 -16
  194. package/src/embeddings/vector-store.ts +486 -77
  195. package/src/embeddings/voyage-provider.ts +313 -0
  196. package/src/errors/errors.test.ts +845 -0
  197. package/src/errors/index.ts +533 -0
  198. package/src/index/ignore-patterns.test.ts +354 -0
  199. package/src/index/ignore-patterns.ts +305 -0
  200. package/src/index/indexer.ts +286 -48
  201. package/src/index/storage.ts +94 -30
  202. package/src/index/types.ts +40 -2
  203. package/src/index/watcher.ts +67 -9
  204. package/src/index.ts +22 -0
  205. package/src/integration/search-keyword.test.ts +678 -0
  206. package/src/mcp/server.ts +135 -6
  207. package/src/parser/parser.ts +18 -19
  208. package/src/parser/section-filter.test.ts +277 -0
  209. package/src/parser/section-filter.ts +125 -3
  210. package/src/search/__tests__/hybrid-search.test.ts +650 -0
  211. package/src/search/bm25-store.ts +366 -0
  212. package/src/search/cross-encoder.test.ts +253 -0
  213. package/src/search/cross-encoder.ts +406 -0
  214. package/src/search/fuzzy-search.test.ts +419 -0
  215. package/src/search/fuzzy-search.ts +273 -0
  216. package/src/search/hybrid-search.ts +448 -0
  217. package/src/search/path-matcher.test.ts +276 -0
  218. package/src/search/path-matcher.ts +33 -0
  219. package/src/search/searcher.test.ts +99 -1
  220. package/src/search/searcher.ts +189 -67
  221. package/src/search/wink-bm25.d.ts +30 -0
  222. package/src/summarization/cli-providers/claude.ts +202 -0
  223. package/src/summarization/cli-providers/detection.test.ts +273 -0
  224. package/src/summarization/cli-providers/detection.ts +118 -0
  225. package/src/summarization/cli-providers/index.ts +8 -0
  226. package/src/summarization/cost.test.ts +139 -0
  227. package/src/summarization/cost.ts +102 -0
  228. package/src/summarization/error-handler.test.ts +127 -0
  229. package/src/summarization/error-handler.ts +111 -0
  230. package/src/summarization/index.ts +102 -0
  231. package/src/summarization/pipeline.test.ts +498 -0
  232. package/src/summarization/pipeline.ts +231 -0
  233. package/src/summarization/prompts.test.ts +269 -0
  234. package/src/summarization/prompts.ts +133 -0
  235. package/src/summarization/provider-factory.test.ts +396 -0
  236. package/src/summarization/provider-factory.ts +178 -0
  237. package/src/summarization/types.ts +184 -0
  238. package/src/summarize/summarizer.ts +104 -35
  239. package/src/types/huggingface-transformers.d.ts +66 -0
  240. package/tests/fixtures/cli/.mdcontext/active-provider.json +7 -0
  241. package/tests/fixtures/cli/.mdcontext/embeddings/openai_text-embedding-3-small_512/vectors.bin +0 -0
  242. package/tests/fixtures/cli/.mdcontext/embeddings/openai_text-embedding-3-small_512/vectors.meta.bin +0 -0
  243. package/tests/fixtures/cli/.mdcontext/indexes/documents.json +4 -4
  244. package/tests/fixtures/cli/.mdcontext/indexes/sections.json +14 -0
  245. package/tests/integration/embed-index.test.ts +712 -0
  246. package/tests/integration/search-context.test.ts +469 -0
  247. package/tests/integration/search-semantic.test.ts +522 -0
  248. package/vitest.config.ts +1 -6
  249. package/AGENTS.md +0 -46
  250. package/tests/fixtures/cli/.mdcontext/vectors.bin +0 -0
  251. package/tests/fixtures/cli/.mdcontext/vectors.meta.json +0 -1264
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Tests for CLI Provider Detection Module
3
+ */
4
+
5
+ import { type ChildProcess, spawn } from 'node:child_process'
6
+ import { EventEmitter } from 'node:events'
7
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
8
+ import type { CLIProviderName } from '../types.js'
9
+ import {
10
+ detectInstalledCLIs,
11
+ getCLIInfo,
12
+ isCLIInstalled,
13
+ KNOWN_CLIS,
14
+ } from './detection.js'
15
+
16
+ vi.mock('node:child_process', () => ({
17
+ spawn: vi.fn(),
18
+ }))
19
+
20
+ const mockSpawn = vi.mocked(spawn)
21
+
22
+ const createMockProcess = (exitCode: number | null, emitError = false) => {
23
+ const proc = new EventEmitter() as ChildProcess
24
+ setTimeout(() => {
25
+ if (emitError) {
26
+ proc.emit('error', new Error('spawn ENOENT'))
27
+ } else {
28
+ proc.emit('close', exitCode)
29
+ }
30
+ }, 0)
31
+ return proc
32
+ }
33
+
34
+ describe('KNOWN_CLIS', () => {
35
+ const expectedProviders: CLIProviderName[] = [
36
+ 'claude',
37
+ 'opencode',
38
+ 'copilot',
39
+ 'aider',
40
+ 'cline',
41
+ 'amp',
42
+ ]
43
+
44
+ it('should contain all expected CLI providers', () => {
45
+ const names = KNOWN_CLIS.map((cli) => cli.name)
46
+ for (const provider of expectedProviders) {
47
+ expect(names).toContain(provider)
48
+ }
49
+ })
50
+
51
+ it('should have required fields for each CLI', () => {
52
+ for (const cli of KNOWN_CLIS) {
53
+ expect(cli).toHaveProperty('name')
54
+ expect(cli).toHaveProperty('command')
55
+ expect(cli).toHaveProperty('displayName')
56
+ expect(cli).toHaveProperty('args')
57
+ expect(cli).toHaveProperty('useStdin')
58
+
59
+ expect(typeof cli.name).toBe('string')
60
+ expect(typeof cli.command).toBe('string')
61
+ expect(typeof cli.displayName).toBe('string')
62
+ expect(Array.isArray(cli.args)).toBe(true)
63
+ expect(typeof cli.useStdin).toBe('boolean')
64
+ }
65
+ })
66
+
67
+ describe('individual CLI configurations', () => {
68
+ it('should have correct claude configuration', () => {
69
+ const claude = KNOWN_CLIS.find((cli) => cli.name === 'claude')
70
+ expect(claude).toBeDefined()
71
+ expect(claude!.command).toBe('claude')
72
+ expect(claude!.displayName).toBe('Claude Code')
73
+ expect(claude!.args).toContain('-p')
74
+ expect(claude!.useStdin).toBe(false)
75
+ })
76
+
77
+ it('should have correct opencode configuration', () => {
78
+ const opencode = KNOWN_CLIS.find((cli) => cli.name === 'opencode')
79
+ expect(opencode).toBeDefined()
80
+ expect(opencode!.command).toBe('opencode')
81
+ expect(opencode!.displayName).toBe('OpenCode')
82
+ expect(opencode!.useStdin).toBe(true)
83
+ })
84
+
85
+ it('should have correct copilot configuration', () => {
86
+ const copilot = KNOWN_CLIS.find((cli) => cli.name === 'copilot')
87
+ expect(copilot).toBeDefined()
88
+ expect(copilot!.command).toBe('gh')
89
+ expect(copilot!.displayName).toBe('GitHub Copilot CLI')
90
+ expect(copilot!.args).toContain('copilot')
91
+ expect(copilot!.useStdin).toBe(true)
92
+ })
93
+
94
+ it('should have correct aider configuration', () => {
95
+ const aider = KNOWN_CLIS.find((cli) => cli.name === 'aider')
96
+ expect(aider).toBeDefined()
97
+ expect(aider!.command).toBe('aider')
98
+ expect(aider!.displayName).toBe('Aider')
99
+ expect(aider!.useStdin).toBe(false)
100
+ })
101
+
102
+ it('should have correct cline configuration', () => {
103
+ const cline = KNOWN_CLIS.find((cli) => cli.name === 'cline')
104
+ expect(cline).toBeDefined()
105
+ expect(cline!.command).toBe('cline')
106
+ expect(cline!.displayName).toBe('Cline')
107
+ expect(cline!.useStdin).toBe(false)
108
+ })
109
+
110
+ it('should have correct amp configuration', () => {
111
+ const amp = KNOWN_CLIS.find((cli) => cli.name === 'amp')
112
+ expect(amp).toBeDefined()
113
+ expect(amp!.command).toBe('amp')
114
+ expect(amp!.displayName).toBe('Amp')
115
+ expect(amp!.useStdin).toBe(false)
116
+ })
117
+ })
118
+ })
119
+
120
+ describe('getCLIInfo', () => {
121
+ it('should return correct info for known providers', () => {
122
+ const claude = getCLIInfo('claude')
123
+ expect(claude).toBeDefined()
124
+ expect(claude!.name).toBe('claude')
125
+ expect(claude!.command).toBe('claude')
126
+
127
+ const copilot = getCLIInfo('copilot')
128
+ expect(copilot).toBeDefined()
129
+ expect(copilot!.name).toBe('copilot')
130
+ expect(copilot!.command).toBe('gh')
131
+ })
132
+
133
+ it('should return undefined for unknown provider', () => {
134
+ const unknown = getCLIInfo('unknown' as CLIProviderName)
135
+ expect(unknown).toBeUndefined()
136
+ })
137
+
138
+ it('should return all fields for a CLI', () => {
139
+ const cli = getCLIInfo('claude')
140
+ expect(cli).toMatchObject({
141
+ name: 'claude',
142
+ command: 'claude',
143
+ displayName: 'Claude Code',
144
+ args: expect.any(Array),
145
+ useStdin: false,
146
+ })
147
+ })
148
+ })
149
+
150
+ describe('isCLIInstalled', () => {
151
+ beforeEach(() => {
152
+ vi.clearAllMocks()
153
+ })
154
+
155
+ afterEach(() => {
156
+ vi.restoreAllMocks()
157
+ })
158
+
159
+ it('should return true when CLI exists (exit code 0)', async () => {
160
+ mockSpawn.mockReturnValue(createMockProcess(0))
161
+
162
+ const result = await isCLIInstalled('claude')
163
+
164
+ expect(result).toBe(true)
165
+ expect(mockSpawn).toHaveBeenCalledWith(
166
+ expect.stringMatching(/^(which|where)$/),
167
+ ['claude'],
168
+ { stdio: ['ignore', 'pipe', 'ignore'] },
169
+ )
170
+ })
171
+
172
+ it('should return false when CLI does not exist (exit code 1)', async () => {
173
+ mockSpawn.mockReturnValue(createMockProcess(1))
174
+
175
+ const result = await isCLIInstalled('claude')
176
+
177
+ expect(result).toBe(false)
178
+ })
179
+
180
+ it('should return false when spawn emits an error', async () => {
181
+ mockSpawn.mockReturnValue(createMockProcess(null, true))
182
+
183
+ const result = await isCLIInstalled('claude')
184
+
185
+ expect(result).toBe(false)
186
+ })
187
+
188
+ it('should return false for unknown provider', async () => {
189
+ const result = await isCLIInstalled('unknown' as CLIProviderName)
190
+
191
+ expect(result).toBe(false)
192
+ expect(mockSpawn).not.toHaveBeenCalled()
193
+ })
194
+
195
+ it('should check the correct command for copilot (gh)', async () => {
196
+ mockSpawn.mockReturnValue(createMockProcess(0))
197
+
198
+ await isCLIInstalled('copilot')
199
+
200
+ expect(mockSpawn).toHaveBeenCalledWith(
201
+ expect.stringMatching(/^(which|where)$/),
202
+ ['gh'],
203
+ expect.any(Object),
204
+ )
205
+ })
206
+ })
207
+
208
+ describe('detectInstalledCLIs', () => {
209
+ beforeEach(() => {
210
+ vi.clearAllMocks()
211
+ })
212
+
213
+ afterEach(() => {
214
+ vi.restoreAllMocks()
215
+ })
216
+
217
+ it('should return all CLIs when all are installed', async () => {
218
+ mockSpawn.mockReturnValue(createMockProcess(0))
219
+
220
+ const result = await detectInstalledCLIs()
221
+
222
+ expect(result.length).toBe(KNOWN_CLIS.length)
223
+ expect(result.map((cli) => cli.name)).toEqual(
224
+ expect.arrayContaining([
225
+ 'claude',
226
+ 'opencode',
227
+ 'copilot',
228
+ 'aider',
229
+ 'cline',
230
+ 'amp',
231
+ ]),
232
+ )
233
+ })
234
+
235
+ it('should return empty array when no CLIs are installed', async () => {
236
+ mockSpawn.mockReturnValue(createMockProcess(1))
237
+
238
+ const result = await detectInstalledCLIs()
239
+
240
+ expect(result).toEqual([])
241
+ })
242
+
243
+ it('should return only installed CLIs', async () => {
244
+ const installedCommands = new Set(['claude', 'gh'])
245
+ mockSpawn.mockImplementation((_cmd, args) => {
246
+ const command = args[0] as string
247
+ const isInstalled = installedCommands.has(command)
248
+ return createMockProcess(isInstalled ? 0 : 1)
249
+ })
250
+
251
+ const result = await detectInstalledCLIs()
252
+
253
+ expect(result.length).toBe(2)
254
+ expect(result.map((cli) => cli.name)).toContain('claude')
255
+ expect(result.map((cli) => cli.name)).toContain('copilot')
256
+ })
257
+
258
+ it('should handle errors gracefully', async () => {
259
+ mockSpawn.mockReturnValue(createMockProcess(null, true))
260
+
261
+ const result = await detectInstalledCLIs()
262
+
263
+ expect(result).toEqual([])
264
+ })
265
+
266
+ it('should check all known CLIs', async () => {
267
+ mockSpawn.mockReturnValue(createMockProcess(0))
268
+
269
+ await detectInstalledCLIs()
270
+
271
+ expect(mockSpawn).toHaveBeenCalledTimes(KNOWN_CLIS.length)
272
+ })
273
+ })
@@ -0,0 +1,118 @@
1
+ /**
2
+ * CLI Provider Detection
3
+ *
4
+ * Detects installed CLI tools that can be used for AI summarization.
5
+ * Uses spawn() with argument arrays for security - NEVER exec() with string interpolation.
6
+ */
7
+
8
+ import { spawn } from 'node:child_process'
9
+ import type { CLIInfo, CLIProviderName } from '../types.js'
10
+
11
+ /**
12
+ * Known CLI tools with their configuration.
13
+ *
14
+ * SECURITY: All CLI invocations use spawn() with argument arrays.
15
+ * The args array is used directly, never interpolated into strings.
16
+ */
17
+ export const KNOWN_CLIS: readonly CLIInfo[] = [
18
+ {
19
+ name: 'claude',
20
+ command: 'claude',
21
+ displayName: 'Claude Code',
22
+ args: ['-p', '--output-format', 'text'],
23
+ useStdin: false, // Uses -p flag for prompt, not stdin
24
+ },
25
+ {
26
+ name: 'opencode',
27
+ command: 'opencode',
28
+ displayName: 'OpenCode',
29
+ args: ['run', '--format', 'text'],
30
+ useStdin: true,
31
+ },
32
+ {
33
+ name: 'copilot',
34
+ command: 'gh',
35
+ displayName: 'GitHub Copilot CLI',
36
+ args: ['copilot', 'explain'],
37
+ useStdin: true,
38
+ },
39
+ {
40
+ name: 'aider',
41
+ command: 'aider',
42
+ displayName: 'Aider',
43
+ args: ['--message'],
44
+ useStdin: false,
45
+ },
46
+ {
47
+ name: 'cline',
48
+ command: 'cline',
49
+ displayName: 'Cline',
50
+ args: ['--prompt'],
51
+ useStdin: false,
52
+ },
53
+ {
54
+ name: 'amp',
55
+ command: 'amp',
56
+ displayName: 'Amp',
57
+ args: ['--prompt'],
58
+ useStdin: false,
59
+ },
60
+ ] as const
61
+
62
+ /**
63
+ * Check if a command exists on the system.
64
+ *
65
+ * SECURITY: Uses spawn() with argument array, not exec() with string interpolation.
66
+ */
67
+ const commandExists = (command: string): Promise<boolean> => {
68
+ return new Promise((resolve) => {
69
+ // Use 'which' on Unix, 'where' on Windows
70
+ const checkCommand = process.platform === 'win32' ? 'where' : 'which'
71
+
72
+ const proc = spawn(checkCommand, [command], {
73
+ stdio: ['ignore', 'pipe', 'ignore'],
74
+ })
75
+
76
+ proc.on('close', (code) => {
77
+ resolve(code === 0)
78
+ })
79
+
80
+ proc.on('error', () => {
81
+ resolve(false)
82
+ })
83
+ })
84
+ }
85
+
86
+ /**
87
+ * Detect all installed CLI tools that can be used for summarization.
88
+ *
89
+ * @returns Array of CLIInfo for installed tools
90
+ */
91
+ export const detectInstalledCLIs = async (): Promise<CLIInfo[]> => {
92
+ const results = await Promise.all(
93
+ KNOWN_CLIS.map(async (cli) => {
94
+ const exists = await commandExists(cli.command)
95
+ return exists ? cli : null
96
+ }),
97
+ )
98
+
99
+ return results.filter((cli): cli is CLIInfo => cli !== null)
100
+ }
101
+
102
+ /**
103
+ * Get CLI info by name.
104
+ */
105
+ export const getCLIInfo = (name: CLIProviderName): CLIInfo | undefined => {
106
+ return KNOWN_CLIS.find((cli) => cli.name === name)
107
+ }
108
+
109
+ /**
110
+ * Check if a specific CLI is installed.
111
+ */
112
+ export const isCLIInstalled = async (
113
+ name: CLIProviderName,
114
+ ): Promise<boolean> => {
115
+ const cli = getCLIInfo(name)
116
+ if (!cli) return false
117
+ return commandExists(cli.command)
118
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * CLI Providers Module
3
+ *
4
+ * Exports all CLI-based summarizers and detection utilities.
5
+ */
6
+
7
+ export * from './claude.js'
8
+ export * from './detection.js'
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Tests for Cost Estimation Module
3
+ */
4
+
5
+ import { describe, expect, it } from 'vitest'
6
+ import {
7
+ API_PRICING,
8
+ estimateSummaryCost,
9
+ estimateTokens,
10
+ formatCostDisplay,
11
+ } from './cost.js'
12
+
13
+ describe('estimateTokens', () => {
14
+ it('should estimate ~4 chars per token', () => {
15
+ expect(estimateTokens('test')).toBe(1)
16
+ expect(estimateTokens('testtest')).toBe(2)
17
+ expect(estimateTokens('x'.repeat(100))).toBe(25)
18
+ })
19
+
20
+ it('should round up partial tokens', () => {
21
+ expect(estimateTokens('abc')).toBe(1) // 0.75 -> 1
22
+ expect(estimateTokens('abcde')).toBe(2) // 1.25 -> 2
23
+ })
24
+
25
+ it('should handle empty string', () => {
26
+ expect(estimateTokens('')).toBe(0)
27
+ })
28
+ })
29
+
30
+ describe('estimateSummaryCost', () => {
31
+ describe('CLI providers (free)', () => {
32
+ it('should return isPaid=false for CLI mode', () => {
33
+ const result = estimateSummaryCost('test input', 'cli', 'claude')
34
+ expect(result.isPaid).toBe(false)
35
+ expect(result.estimatedCost).toBe(0)
36
+ expect(result.formattedCost).toBe('FREE (subscription)')
37
+ })
38
+
39
+ it('should still estimate tokens for CLI mode', () => {
40
+ const input = 'x'.repeat(400) // ~100 tokens
41
+ const result = estimateSummaryCost(input, 'cli', 'claude')
42
+ expect(result.inputTokens).toBe(100)
43
+ expect(result.outputTokens).toBe(500) // default
44
+ })
45
+ })
46
+
47
+ describe('API providers (paid)', () => {
48
+ it('should calculate cost for DeepSeek', () => {
49
+ const input = 'x'.repeat(4000) // ~1000 tokens
50
+ const result = estimateSummaryCost(input, 'api', 'deepseek', 500)
51
+
52
+ expect(result.inputTokens).toBe(1000)
53
+ expect(result.outputTokens).toBe(500)
54
+ expect(result.isPaid).toBe(true)
55
+ // Input: 1000 * 0.14 / 1M = 0.00014
56
+ // Output: 500 * 0.56 / 1M = 0.00028
57
+ // Total: 0.00042
58
+ expect(result.estimatedCost).toBeCloseTo(0.00042, 5)
59
+ })
60
+
61
+ it('should calculate cost for Anthropic (more expensive)', () => {
62
+ const input = 'x'.repeat(4000) // ~1000 tokens
63
+ const result = estimateSummaryCost(input, 'api', 'anthropic', 500)
64
+
65
+ expect(result.isPaid).toBe(true)
66
+ // Input: 1000 * 3.0 / 1M = 0.003
67
+ // Output: 500 * 15.0 / 1M = 0.0075
68
+ // Total: 0.0105
69
+ expect(result.estimatedCost).toBeCloseTo(0.0105, 4)
70
+ })
71
+
72
+ it('should calculate cost for OpenAI', () => {
73
+ const input = 'x'.repeat(4000)
74
+ const result = estimateSummaryCost(input, 'api', 'openai', 500)
75
+
76
+ expect(result.isPaid).toBe(true)
77
+ // Input: 1000 * 1.75 / 1M = 0.00175
78
+ // Output: 500 * 14.0 / 1M = 0.007
79
+ // Total: 0.00875
80
+ expect(result.estimatedCost).toBeCloseTo(0.00875, 5)
81
+ })
82
+
83
+ it('should handle CLI provider used with API mode (falls back to deepseek pricing)', () => {
84
+ const input = 'x'.repeat(4000)
85
+ // When a CLI provider is used with API mode, it falls back to deepseek pricing
86
+ const result = estimateSummaryCost(input, 'api', 'claude', 500)
87
+
88
+ expect(result.isPaid).toBe(true)
89
+ expect(result.estimatedCost).toBeCloseTo(0.00042, 5)
90
+ })
91
+ })
92
+
93
+ describe('provider comparison', () => {
94
+ it('should show Qwen as cheapest API provider', () => {
95
+ const input = 'x'.repeat(4000)
96
+ const qwen = estimateSummaryCost(input, 'api', 'qwen', 500)
97
+ const deepseek = estimateSummaryCost(input, 'api', 'deepseek', 500)
98
+ const anthropic = estimateSummaryCost(input, 'api', 'anthropic', 500)
99
+
100
+ expect(qwen.estimatedCost).toBeLessThan(deepseek.estimatedCost)
101
+ expect(deepseek.estimatedCost).toBeLessThan(anthropic.estimatedCost)
102
+ })
103
+ })
104
+ })
105
+
106
+ describe('formatCostDisplay', () => {
107
+ it('should format free CLI cost', () => {
108
+ const estimate = estimateSummaryCost('test', 'cli', 'claude')
109
+ const display = formatCostDisplay(estimate)
110
+ expect(display).toContain('FREE')
111
+ expect(display).toContain('claude')
112
+ })
113
+
114
+ it('should format paid API cost', () => {
115
+ const estimate = estimateSummaryCost('test', 'api', 'deepseek')
116
+ const display = formatCostDisplay(estimate)
117
+ expect(display).toContain('Estimated cost')
118
+ expect(display).toContain('$')
119
+ })
120
+ })
121
+
122
+ describe('API_PRICING', () => {
123
+ it('should have all expected providers', () => {
124
+ expect(API_PRICING.deepseek).toBeDefined()
125
+ expect(API_PRICING.qwen).toBeDefined()
126
+ expect(API_PRICING.anthropic).toBeDefined()
127
+ expect(API_PRICING.openai).toBeDefined()
128
+ expect(API_PRICING.gemini).toBeDefined()
129
+ })
130
+
131
+ it('should have valid pricing (input < output)', () => {
132
+ for (const pricing of Object.values(API_PRICING)) {
133
+ expect(pricing.input).toBeGreaterThan(0)
134
+ expect(pricing.output).toBeGreaterThan(0)
135
+ // Output tokens typically cost more
136
+ expect(pricing.output).toBeGreaterThanOrEqual(pricing.input)
137
+ }
138
+ })
139
+ })
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Cost Estimation for AI Summarization
3
+ *
4
+ * Provides cost estimates for API providers before running queries.
5
+ * CLI providers are free (subscription-based), so cost is always 0.
6
+ */
7
+
8
+ import type {
9
+ APIProviderName,
10
+ CLIProviderName,
11
+ SummarizationMode,
12
+ } from './types.js'
13
+
14
+ /**
15
+ * Type guard to check if a provider is an API provider
16
+ */
17
+ const isAPIProvider = (provider: string): provider is APIProviderName => {
18
+ return ['deepseek', 'anthropic', 'openai', 'gemini', 'qwen'].includes(
19
+ provider,
20
+ )
21
+ }
22
+
23
+ /**
24
+ * Pricing per 1 million tokens for each provider.
25
+ * Values as of January 2026.
26
+ */
27
+ export const API_PRICING: Record<
28
+ APIProviderName,
29
+ { input: number; output: number; displayName: string }
30
+ > = {
31
+ deepseek: { input: 0.14, output: 0.56, displayName: 'DeepSeek' },
32
+ qwen: { input: 0.03, output: 0.12, displayName: 'Qwen' },
33
+ anthropic: { input: 3.0, output: 15.0, displayName: 'Anthropic Claude' },
34
+ openai: { input: 1.75, output: 14.0, displayName: 'OpenAI GPT' },
35
+ gemini: { input: 0.3, output: 2.5, displayName: 'Google Gemini' },
36
+ }
37
+
38
+ /**
39
+ * Cost estimate result
40
+ */
41
+ export interface CostEstimate {
42
+ readonly inputTokens: number
43
+ readonly outputTokens: number
44
+ readonly estimatedCost: number
45
+ readonly provider: string
46
+ readonly isPaid: boolean
47
+ readonly formattedCost: string
48
+ }
49
+
50
+ /**
51
+ * Simple token estimation (4 chars ≈ 1 token).
52
+ */
53
+ export const estimateTokens = (text: string): number => {
54
+ return Math.ceil(text.length / 4)
55
+ }
56
+
57
+ /**
58
+ * Estimate the cost of summarizing text.
59
+ */
60
+ export const estimateSummaryCost = (
61
+ input: string,
62
+ mode: SummarizationMode,
63
+ provider: CLIProviderName | APIProviderName,
64
+ maxOutputTokens: number = 500,
65
+ ): CostEstimate => {
66
+ const inputTokens = estimateTokens(input)
67
+
68
+ if (mode === 'cli') {
69
+ return {
70
+ inputTokens,
71
+ outputTokens: maxOutputTokens,
72
+ estimatedCost: 0,
73
+ provider,
74
+ isPaid: false,
75
+ formattedCost: 'FREE (subscription)',
76
+ }
77
+ }
78
+
79
+ // For API mode, use API pricing (default to deepseek if provider not found)
80
+ const pricing = isAPIProvider(provider)
81
+ ? API_PRICING[provider]
82
+ : API_PRICING.deepseek
83
+ const inputCost = (inputTokens * pricing.input) / 1_000_000
84
+ const outputCost = (maxOutputTokens * pricing.output) / 1_000_000
85
+ const totalCost = inputCost + outputCost
86
+
87
+ return {
88
+ inputTokens,
89
+ outputTokens: maxOutputTokens,
90
+ estimatedCost: totalCost,
91
+ provider,
92
+ isPaid: true,
93
+ formattedCost: `$${totalCost.toFixed(4)}`,
94
+ }
95
+ }
96
+
97
+ export const formatCostDisplay = (estimate: CostEstimate): string => {
98
+ if (!estimate.isPaid) {
99
+ return `Using ${estimate.provider} (subscription - FREE)`
100
+ }
101
+ return `Estimated cost: ${estimate.formattedCost}`
102
+ }