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.
- package/.changeset/config.json +9 -9
- package/.claude/settings.local.json +25 -0
- package/.github/workflows/claude-code-review.yml +44 -0
- package/.github/workflows/claude.yml +85 -0
- package/CONTRIBUTING.md +186 -0
- package/NOTES/NOTES +44 -0
- package/README.md +206 -3
- package/biome.json +1 -1
- package/dist/chunk-23UPXDNL.js +3044 -0
- package/dist/chunk-2W7MO2DL.js +1366 -0
- package/dist/chunk-3NUAZGMA.js +1689 -0
- package/dist/chunk-7TOWB2XB.js +366 -0
- package/dist/chunk-7XOTOADQ.js +3065 -0
- package/dist/chunk-AH2PDM2K.js +3042 -0
- package/dist/chunk-BNXWSZ63.js +3742 -0
- package/dist/chunk-BTL5DJVU.js +3222 -0
- package/dist/chunk-HDHYG7E4.js +104 -0
- package/dist/chunk-HLR4KZBP.js +3234 -0
- package/dist/chunk-IP3FRFEB.js +1045 -0
- package/dist/chunk-KHU56VDO.js +3042 -0
- package/dist/chunk-KRYIFLQR.js +85 -89
- package/dist/chunk-LBSDNLEM.js +287 -0
- package/dist/chunk-MNTQ7HCP.js +2643 -0
- package/dist/chunk-MUJELQQ6.js +1387 -0
- package/dist/chunk-MXJGMSLV.js +2199 -0
- package/dist/chunk-N6QJGC3Z.js +2636 -0
- package/dist/chunk-OBELGBPM.js +1713 -0
- package/dist/chunk-OT7R5XTA.js +3192 -0
- package/dist/chunk-P7X4RA2T.js +106 -0
- package/dist/chunk-PIDUQNC2.js +3185 -0
- package/dist/chunk-POGCDIH4.js +3187 -0
- package/dist/chunk-PSIEOQGZ.js +3043 -0
- package/dist/chunk-PVRT3IHA.js +3238 -0
- package/dist/chunk-QNN4TT23.js +1430 -0
- package/dist/chunk-RE3R45RJ.js +3042 -0
- package/dist/chunk-S7E6TFX6.js +718 -657
- package/dist/chunk-SG6GLU4U.js +1378 -0
- package/dist/chunk-SJCDV2ST.js +274 -0
- package/dist/chunk-SYE5XLF3.js +104 -0
- package/dist/chunk-T5VLYBZD.js +103 -0
- package/dist/chunk-TOQB7VWU.js +3238 -0
- package/dist/chunk-VFNMZ4ZQ.js +3228 -0
- package/dist/chunk-VVTGZNBT.js +1533 -1423
- package/dist/chunk-W7Q4RFEV.js +104 -0
- package/dist/chunk-XTYYVRLO.js +3190 -0
- package/dist/chunk-Y6MDYVJD.js +3063 -0
- package/dist/cli/main.js +4072 -629
- package/dist/index.d.ts +420 -33
- package/dist/index.js +8 -15
- package/dist/mcp/server.js +103 -7
- package/dist/schema-BAWSG7KY.js +22 -0
- package/dist/schema-E3QUPL26.js +20 -0
- package/dist/schema-EHL7WUT6.js +20 -0
- package/docs/019-USAGE.md +44 -5
- package/docs/020-current-implementation.md +8 -8
- package/docs/021-DOGFOODING-FINDINGS.md +1 -1
- package/docs/CONFIG.md +1123 -0
- package/docs/ERRORS.md +383 -0
- package/docs/summarization.md +320 -0
- package/justfile +40 -0
- package/package.json +39 -33
- package/research/INDEX.md +315 -0
- package/research/code-review/README.md +90 -0
- package/research/code-review/cli-error-handling-review.md +979 -0
- package/research/code-review/code-review-validation-report.md +464 -0
- package/research/code-review/main-ts-review.md +1128 -0
- package/research/config-docs/SUMMARY.md +357 -0
- package/research/config-docs/TEST-RESULTS.md +776 -0
- package/research/config-docs/TODO.md +542 -0
- package/research/config-docs/analysis.md +744 -0
- package/research/config-docs/fix-validation.md +502 -0
- package/research/config-docs/help-audit.md +264 -0
- package/research/config-docs/help-system-analysis.md +890 -0
- package/research/frontmatter/COMMENTS-ARE-SKIPPED.md +149 -0
- package/research/frontmatter/LLM-CODE-NAVIGATION.md +276 -0
- package/research/issue-review.md +603 -0
- package/research/llm-summarization/agent-cli-tools-2026.md +1082 -0
- package/research/llm-summarization/alternative-providers-2026.md +1428 -0
- package/research/llm-summarization/anthropic-2026.md +367 -0
- package/research/llm-summarization/claude-cli-integration.md +1706 -0
- package/research/llm-summarization/cli-integration-patterns.md +3155 -0
- package/research/llm-summarization/openai-2026.md +473 -0
- package/research/llm-summarization/openai-compatible-providers-2026.md +1022 -0
- package/research/llm-summarization/opencode-cli-integration.md +1552 -0
- package/research/llm-summarization/prompt-engineering-2026.md +1426 -0
- package/research/llm-summarization/prototype-results.md +56 -0
- package/research/llm-summarization/provider-switching-patterns-2026.md +2153 -0
- package/research/llm-summarization/typescript-llm-libraries-2026.md +2436 -0
- package/research/mdcontext-pudding/00-EXECUTIVE-SUMMARY.md +282 -0
- package/research/mdcontext-pudding/01-index-embed.md +956 -0
- package/research/mdcontext-pudding/02-search-COMMANDS.md +142 -0
- package/research/mdcontext-pudding/02-search-SUMMARY.md +146 -0
- package/research/mdcontext-pudding/02-search.md +970 -0
- package/research/mdcontext-pudding/03-context.md +779 -0
- package/research/mdcontext-pudding/04-navigation-and-analytics.md +803 -0
- package/research/mdcontext-pudding/04-tree.md +704 -0
- package/research/mdcontext-pudding/05-config.md +1038 -0
- package/research/mdcontext-pudding/06-links-summary.txt +87 -0
- package/research/mdcontext-pudding/06-links.md +679 -0
- package/research/mdcontext-pudding/07-stats.md +693 -0
- package/research/mdcontext-pudding/BUG-FIX-PLAN.md +388 -0
- package/research/mdcontext-pudding/P0-BUG-VALIDATION.md +167 -0
- package/research/mdcontext-pudding/README.md +168 -0
- package/research/mdcontext-pudding/TESTING-SUMMARY.md +128 -0
- package/research/research-quality-review.md +834 -0
- package/research/semantic-search/embedding-text-analysis.md +156 -0
- package/research/semantic-search/multi-word-failure-reproduction.md +171 -0
- package/research/semantic-search/query-processing-analysis.md +207 -0
- package/research/semantic-search/root-cause-and-solution.md +114 -0
- package/research/semantic-search/threshold-validation-report.md +69 -0
- package/research/semantic-search/vector-search-analysis.md +63 -0
- package/research/test-path-issues.md +276 -0
- package/review/ALP-76/1-error-type-design.md +962 -0
- package/review/ALP-76/2-error-handling-patterns.md +906 -0
- package/review/ALP-76/3-error-presentation.md +624 -0
- package/review/ALP-76/4-test-coverage.md +625 -0
- package/review/ALP-76/5-migration-completeness.md +440 -0
- package/review/ALP-76/6-effect-best-practices.md +755 -0
- package/scripts/apply-branch-protection.sh +47 -0
- package/scripts/branch-protection-templates.json +79 -0
- package/scripts/prototype-summarization.ts +346 -0
- package/scripts/rebuild-hnswlib.js +32 -37
- package/scripts/setup-branch-protection.sh +64 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/active-provider.json +7 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/bm25.json +541 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/bm25.meta.json +5 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/config.json +8 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/embeddings/openai_text-embedding-3-small_512/vectors.bin +0 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/embeddings/openai_text-embedding-3-small_512/vectors.meta.bin +0 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/indexes/documents.json +60 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/indexes/links.json +13 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/.mdcontext/indexes/sections.json +1197 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/configuration-management.md +99 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/distributed-systems.md +92 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/error-handling.md +78 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/failure-automation.md +55 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/job-context.md +69 -0
- package/src/__tests__/fixtures/semantic-search/multi-word-corpus/process-orchestration.md +99 -0
- package/src/cli/argv-preprocessor.test.ts +2 -2
- package/src/cli/cli.test.ts +230 -33
- package/src/cli/commands/config-cmd.ts +642 -0
- package/src/cli/commands/context.ts +97 -9
- package/src/cli/commands/duplicates.ts +122 -0
- package/src/cli/commands/embeddings.ts +529 -0
- package/src/cli/commands/index-cmd.ts +210 -30
- package/src/cli/commands/index.ts +3 -0
- package/src/cli/commands/search.ts +894 -64
- package/src/cli/commands/stats.ts +3 -0
- package/src/cli/commands/tree.ts +26 -5
- package/src/cli/config-layer.ts +176 -0
- package/src/cli/error-handler.test.ts +235 -0
- package/src/cli/error-handler.ts +655 -0
- package/src/cli/flag-schemas.ts +66 -0
- package/src/cli/help.ts +209 -7
- package/src/cli/main.ts +348 -58
- package/src/cli/options.ts +10 -0
- package/src/cli/shared-error-handling.ts +199 -0
- package/src/cli/utils.ts +150 -17
- package/src/config/file-provider.test.ts +320 -0
- package/src/config/file-provider.ts +273 -0
- package/src/config/index.ts +72 -0
- package/src/config/integration.test.ts +667 -0
- package/src/config/precedence.test.ts +277 -0
- package/src/config/precedence.ts +451 -0
- package/src/config/schema.test.ts +414 -0
- package/src/config/schema.ts +603 -0
- package/src/config/service.test.ts +320 -0
- package/src/config/service.ts +243 -0
- package/src/config/testing.test.ts +264 -0
- package/src/config/testing.ts +110 -0
- package/src/core/types.ts +6 -33
- package/src/duplicates/detector.test.ts +183 -0
- package/src/duplicates/detector.ts +414 -0
- package/src/duplicates/index.ts +18 -0
- package/src/embeddings/embedding-namespace.test.ts +300 -0
- package/src/embeddings/embedding-namespace.ts +947 -0
- package/src/embeddings/heading-boost.test.ts +222 -0
- package/src/embeddings/hnsw-build-options.test.ts +198 -0
- package/src/embeddings/hyde.test.ts +272 -0
- package/src/embeddings/hyde.ts +264 -0
- package/src/embeddings/index.ts +2 -0
- package/src/embeddings/openai-provider.ts +332 -83
- package/src/embeddings/pricing.json +22 -0
- package/src/embeddings/provider-constants.ts +204 -0
- package/src/embeddings/provider-errors.test.ts +967 -0
- package/src/embeddings/provider-errors.ts +565 -0
- package/src/embeddings/provider-factory.test.ts +240 -0
- package/src/embeddings/provider-factory.ts +225 -0
- package/src/embeddings/provider-integration.test.ts +788 -0
- package/src/embeddings/query-preprocessing.test.ts +187 -0
- package/src/embeddings/semantic-search-threshold.test.ts +508 -0
- package/src/embeddings/semantic-search.ts +780 -93
- package/src/embeddings/types.ts +293 -16
- package/src/embeddings/vector-store.ts +486 -77
- package/src/embeddings/voyage-provider.ts +313 -0
- package/src/errors/errors.test.ts +845 -0
- package/src/errors/index.ts +533 -0
- package/src/index/ignore-patterns.test.ts +354 -0
- package/src/index/ignore-patterns.ts +305 -0
- package/src/index/indexer.ts +286 -48
- package/src/index/storage.ts +94 -30
- package/src/index/types.ts +40 -2
- package/src/index/watcher.ts +67 -9
- package/src/index.ts +22 -0
- package/src/integration/search-keyword.test.ts +678 -0
- package/src/mcp/server.ts +135 -6
- package/src/parser/parser.ts +18 -19
- package/src/parser/section-filter.test.ts +277 -0
- package/src/parser/section-filter.ts +125 -3
- package/src/search/__tests__/hybrid-search.test.ts +650 -0
- package/src/search/bm25-store.ts +366 -0
- package/src/search/cross-encoder.test.ts +253 -0
- package/src/search/cross-encoder.ts +406 -0
- package/src/search/fuzzy-search.test.ts +419 -0
- package/src/search/fuzzy-search.ts +273 -0
- package/src/search/hybrid-search.ts +448 -0
- package/src/search/path-matcher.test.ts +276 -0
- package/src/search/path-matcher.ts +33 -0
- package/src/search/searcher.test.ts +99 -1
- package/src/search/searcher.ts +189 -67
- package/src/search/wink-bm25.d.ts +30 -0
- package/src/summarization/cli-providers/claude.ts +202 -0
- package/src/summarization/cli-providers/detection.test.ts +273 -0
- package/src/summarization/cli-providers/detection.ts +118 -0
- package/src/summarization/cli-providers/index.ts +8 -0
- package/src/summarization/cost.test.ts +139 -0
- package/src/summarization/cost.ts +102 -0
- package/src/summarization/error-handler.test.ts +127 -0
- package/src/summarization/error-handler.ts +111 -0
- package/src/summarization/index.ts +102 -0
- package/src/summarization/pipeline.test.ts +498 -0
- package/src/summarization/pipeline.ts +231 -0
- package/src/summarization/prompts.test.ts +269 -0
- package/src/summarization/prompts.ts +133 -0
- package/src/summarization/provider-factory.test.ts +396 -0
- package/src/summarization/provider-factory.ts +178 -0
- package/src/summarization/types.ts +184 -0
- package/src/summarize/summarizer.ts +104 -35
- package/src/types/huggingface-transformers.d.ts +66 -0
- package/tests/fixtures/cli/.mdcontext/active-provider.json +7 -0
- package/tests/fixtures/cli/.mdcontext/embeddings/openai_text-embedding-3-small_512/vectors.bin +0 -0
- package/tests/fixtures/cli/.mdcontext/embeddings/openai_text-embedding-3-small_512/vectors.meta.bin +0 -0
- package/tests/fixtures/cli/.mdcontext/indexes/documents.json +4 -4
- package/tests/fixtures/cli/.mdcontext/indexes/sections.json +14 -0
- package/tests/integration/embed-index.test.ts +712 -0
- package/tests/integration/search-context.test.ts +469 -0
- package/tests/integration/search-semantic.test.ts +522 -0
- package/vitest.config.ts +1 -6
- package/AGENTS.md +0 -46
- package/tests/fixtures/cli/.mdcontext/vectors.bin +0 -0
- 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,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
|
+
}
|