mdcontext 0.0.1 → 0.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 (140) hide show
  1. package/.changeset/README.md +28 -0
  2. package/.changeset/config.json +11 -0
  3. package/.github/workflows/ci.yml +83 -0
  4. package/.github/workflows/release.yml +113 -0
  5. package/.tldrignore +112 -0
  6. package/AGENTS.md +46 -0
  7. package/BACKLOG.md +338 -0
  8. package/README.md +231 -11
  9. package/biome.json +36 -0
  10. package/cspell.config.yaml +14 -0
  11. package/dist/chunk-KRYIFLQR.js +92 -0
  12. package/dist/chunk-S7E6TFX6.js +742 -0
  13. package/dist/chunk-VVTGZNBT.js +1519 -0
  14. package/dist/cli/main.d.ts +1 -0
  15. package/dist/cli/main.js +2015 -0
  16. package/dist/index.d.ts +266 -0
  17. package/dist/index.js +86 -0
  18. package/dist/mcp/server.d.ts +1 -0
  19. package/dist/mcp/server.js +376 -0
  20. package/docs/019-USAGE.md +586 -0
  21. package/docs/020-current-implementation.md +364 -0
  22. package/docs/021-DOGFOODING-FINDINGS.md +175 -0
  23. package/docs/BACKLOG.md +80 -0
  24. package/docs/DESIGN.md +439 -0
  25. package/docs/PROJECT.md +88 -0
  26. package/docs/ROADMAP.md +407 -0
  27. package/docs/test-links.md +9 -0
  28. package/package.json +69 -10
  29. package/pnpm-workspace.yaml +5 -0
  30. package/research/config-analysis/01-current-implementation.md +470 -0
  31. package/research/config-analysis/02-strategy-recommendation.md +428 -0
  32. package/research/config-analysis/03-task-candidates.md +715 -0
  33. package/research/config-analysis/033-research-configuration-management.md +828 -0
  34. package/research/config-analysis/034-research-effect-cli-config.md +1504 -0
  35. package/research/config-analysis/04-consolidated-task-candidates.md +277 -0
  36. package/research/dogfood/consolidated-tool-evaluation.md +373 -0
  37. package/research/dogfood/strategy-a/a-synthesis.md +184 -0
  38. package/research/dogfood/strategy-a/a1-docs.md +226 -0
  39. package/research/dogfood/strategy-a/a2-amorphic.md +156 -0
  40. package/research/dogfood/strategy-a/a3-llm.md +164 -0
  41. package/research/dogfood/strategy-b/b-synthesis.md +228 -0
  42. package/research/dogfood/strategy-b/b1-architecture.md +207 -0
  43. package/research/dogfood/strategy-b/b2-gaps.md +258 -0
  44. package/research/dogfood/strategy-b/b3-workflows.md +250 -0
  45. package/research/dogfood/strategy-c/c-synthesis.md +451 -0
  46. package/research/dogfood/strategy-c/c1-explorer.md +192 -0
  47. package/research/dogfood/strategy-c/c2-diver-memory.md +145 -0
  48. package/research/dogfood/strategy-c/c3-diver-control.md +148 -0
  49. package/research/dogfood/strategy-c/c4-diver-failure.md +151 -0
  50. package/research/dogfood/strategy-c/c5-diver-execution.md +221 -0
  51. package/research/dogfood/strategy-c/c6-diver-org.md +221 -0
  52. package/research/effect-cli-error-handling.md +845 -0
  53. package/research/effect-errors-as-values.md +943 -0
  54. package/research/errors-task-analysis/00-consolidated-tasks.md +207 -0
  55. package/research/errors-task-analysis/cli-commands-analysis.md +909 -0
  56. package/research/errors-task-analysis/embeddings-analysis.md +709 -0
  57. package/research/errors-task-analysis/index-search-analysis.md +812 -0
  58. package/research/mdcontext-error-analysis.md +521 -0
  59. package/research/npm_publish/011-npm-workflow-research-agent2.md +792 -0
  60. package/research/npm_publish/012-npm-workflow-research-agent1.md +530 -0
  61. package/research/npm_publish/013-npm-workflow-research-agent3.md +722 -0
  62. package/research/npm_publish/014-npm-workflow-synthesis.md +556 -0
  63. package/research/npm_publish/031-npm-workflow-task-analysis.md +134 -0
  64. package/research/semantic-search/002-research-embedding-models.md +490 -0
  65. package/research/semantic-search/003-research-rag-alternatives.md +523 -0
  66. package/research/semantic-search/004-research-vector-search.md +841 -0
  67. package/research/semantic-search/032-research-semantic-search.md +427 -0
  68. package/research/task-management-2026/00-synthesis-recommendations.md +295 -0
  69. package/research/task-management-2026/01-ai-workflow-tools.md +416 -0
  70. package/research/task-management-2026/02-agent-framework-patterns.md +476 -0
  71. package/research/task-management-2026/03-lightweight-file-based.md +567 -0
  72. package/research/task-management-2026/04-established-tools-ai-features.md +541 -0
  73. package/research/task-management-2026/linear/01-core-features-workflow.md +771 -0
  74. package/research/task-management-2026/linear/02-api-integrations.md +930 -0
  75. package/research/task-management-2026/linear/03-ai-features.md +368 -0
  76. package/research/task-management-2026/linear/04-pricing-setup.md +205 -0
  77. package/research/task-management-2026/linear/05-usage-patterns-best-practices.md +605 -0
  78. package/scripts/rebuild-hnswlib.js +63 -0
  79. package/src/cli/argv-preprocessor.test.ts +210 -0
  80. package/src/cli/argv-preprocessor.ts +202 -0
  81. package/src/cli/cli.test.ts +430 -0
  82. package/src/cli/commands/backlinks.ts +54 -0
  83. package/src/cli/commands/context.ts +197 -0
  84. package/src/cli/commands/index-cmd.ts +300 -0
  85. package/src/cli/commands/index.ts +13 -0
  86. package/src/cli/commands/links.ts +52 -0
  87. package/src/cli/commands/search.ts +451 -0
  88. package/src/cli/commands/stats.ts +146 -0
  89. package/src/cli/commands/tree.ts +107 -0
  90. package/src/cli/flag-schemas.ts +275 -0
  91. package/src/cli/help.ts +386 -0
  92. package/src/cli/index.ts +9 -0
  93. package/src/cli/main.ts +145 -0
  94. package/src/cli/options.ts +31 -0
  95. package/src/cli/typo-suggester.test.ts +105 -0
  96. package/src/cli/typo-suggester.ts +130 -0
  97. package/src/cli/utils.ts +126 -0
  98. package/src/core/index.ts +1 -0
  99. package/src/core/types.ts +140 -0
  100. package/src/embeddings/index.ts +8 -0
  101. package/src/embeddings/openai-provider.ts +165 -0
  102. package/src/embeddings/semantic-search.ts +583 -0
  103. package/src/embeddings/types.ts +82 -0
  104. package/src/embeddings/vector-store.ts +299 -0
  105. package/src/index/index.ts +4 -0
  106. package/src/index/indexer.ts +446 -0
  107. package/src/index/storage.ts +196 -0
  108. package/src/index/types.ts +109 -0
  109. package/src/index/watcher.ts +131 -0
  110. package/src/index.ts +8 -0
  111. package/src/mcp/server.ts +483 -0
  112. package/src/parser/index.ts +1 -0
  113. package/src/parser/parser.test.ts +291 -0
  114. package/src/parser/parser.ts +395 -0
  115. package/src/parser/section-filter.ts +270 -0
  116. package/src/search/query-parser.test.ts +260 -0
  117. package/src/search/query-parser.ts +319 -0
  118. package/src/search/searcher.test.ts +182 -0
  119. package/src/search/searcher.ts +602 -0
  120. package/src/summarize/budget-bugs.test.ts +620 -0
  121. package/src/summarize/formatters.ts +419 -0
  122. package/src/summarize/index.ts +20 -0
  123. package/src/summarize/summarizer.test.ts +275 -0
  124. package/src/summarize/summarizer.ts +528 -0
  125. package/src/summarize/verify-bugs.test.ts +238 -0
  126. package/src/utils/index.ts +1 -0
  127. package/src/utils/tokens.test.ts +142 -0
  128. package/src/utils/tokens.ts +186 -0
  129. package/tests/fixtures/cli/.mdcontext/config.json +8 -0
  130. package/tests/fixtures/cli/.mdcontext/indexes/documents.json +33 -0
  131. package/tests/fixtures/cli/.mdcontext/indexes/links.json +12 -0
  132. package/tests/fixtures/cli/.mdcontext/indexes/sections.json +233 -0
  133. package/tests/fixtures/cli/.mdcontext/vectors.bin +0 -0
  134. package/tests/fixtures/cli/.mdcontext/vectors.meta.json +1264 -0
  135. package/tests/fixtures/cli/README.md +9 -0
  136. package/tests/fixtures/cli/api-reference.md +11 -0
  137. package/tests/fixtures/cli/getting-started.md +11 -0
  138. package/tsconfig.json +26 -0
  139. package/vitest.config.ts +21 -0
  140. package/vitest.setup.ts +12 -0
@@ -0,0 +1,430 @@
1
+ // cspell:words jsno limt xyznonexistent123
2
+ /**
3
+ * E2E tests for mdcontext CLI commands
4
+ * Tests actual CLI execution against dynamically generated test fixtures
5
+ *
6
+ * Test fixture setup:
7
+ * - beforeAll:
8
+ * - When REBUILD_TEST_INDEX=true: Builds index (documents, sections, links) - fast, no API key needed
9
+ * - When INCLUDE_EMBED_TESTS=true: Also builds embeddings (requires OPENAI_API_KEY)
10
+ * - afterAll: frees tiktoken encoder
11
+ *
12
+ * Running tests:
13
+ * - `pnpm test` - Runs with keyword search only (no API key needed)
14
+ * - `pnpm test:full` - Runs all tests including semantic search (requires OPENAI_API_KEY)
15
+ */
16
+
17
+ import { exec } from 'node:child_process'
18
+ import * as path from 'node:path'
19
+ import { promisify } from 'node:util'
20
+ import { Effect } from 'effect'
21
+ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
22
+ import { buildEmbeddings } from '../embeddings/semantic-search.js'
23
+ import { buildIndex } from '../index/indexer.js'
24
+ import { freeEncoder } from '../utils/tokens.js'
25
+
26
+ const execAsync = promisify(exec)
27
+
28
+ const REBUILD_TEST_INDEX = process.env.REBUILD_TEST_INDEX === 'true'
29
+ const INCLUDE_EMBED_TESTS = process.env.INCLUDE_EMBED_TESTS === 'true'
30
+ const TEST_FIXTURE_DIR = path.join(process.cwd(), 'tests', 'fixtures', 'cli')
31
+ const CLI = `node ${path.join(process.cwd(), 'dist', 'cli', 'main.js')}`
32
+
33
+ const run = async (
34
+ args: string,
35
+ options: { cwd?: string; expectError?: boolean } = {},
36
+ ): Promise<string> => {
37
+ const cwd = options.cwd ?? TEST_FIXTURE_DIR
38
+ try {
39
+ const { stdout } = await execAsync(`${CLI} ${args}`, {
40
+ cwd,
41
+ encoding: 'utf-8',
42
+ })
43
+ return stdout.trim()
44
+ } catch (error: unknown) {
45
+ if (options.expectError) {
46
+ const execError = error as { stderr?: string; stdout?: string }
47
+ return execError.stderr || execError.stdout || ''
48
+ }
49
+ throw error
50
+ }
51
+ }
52
+
53
+ describe.concurrent('mdcontext CLI e2e', () => {
54
+ beforeAll(async () => {
55
+ if (REBUILD_TEST_INDEX) {
56
+ // Build the index and embeddings only once for faster tests
57
+ console.log('Rebuilding test fixture index and embeddings...')
58
+ // Build the index (fast, no API key needed)
59
+ await Effect.runPromise(buildIndex(TEST_FIXTURE_DIR, { force: true }))
60
+ console.log('Index rebuilt.')
61
+
62
+ if (INCLUDE_EMBED_TESTS) {
63
+ console.log('Rebuilding test fixture embeddings...')
64
+ await Effect.runPromise(
65
+ buildEmbeddings(TEST_FIXTURE_DIR, { force: true }),
66
+ )
67
+ console.log('Embeddings rebuilt.')
68
+ }
69
+ }
70
+ })
71
+
72
+ afterAll(async () => {
73
+ // Free tiktoken encoder to prevent process hang
74
+ freeEncoder()
75
+ })
76
+
77
+ describe('--version', () => {
78
+ it('shows version number', async () => {
79
+ const output = await run('--version')
80
+ expect(output).toMatch(/^\d+\.\d+\.\d+$/)
81
+ })
82
+ })
83
+
84
+ describe('--help', () => {
85
+ it('shows help with all commands', async () => {
86
+ const output = await run('--help')
87
+ expect(output).toContain('index')
88
+ expect(output).toContain('search')
89
+ expect(output).toContain('context')
90
+ expect(output).toContain('tree')
91
+ expect(output).toContain('links')
92
+ expect(output).toContain('backlinks')
93
+ expect(output).toContain('stats')
94
+ })
95
+ })
96
+
97
+ describe('subcommand --help', () => {
98
+ const subcommands = [
99
+ 'index',
100
+ 'search',
101
+ 'context',
102
+ 'tree',
103
+ 'links',
104
+ 'backlinks',
105
+ 'stats',
106
+ ]
107
+
108
+ for (const cmd of subcommands) {
109
+ it(`${cmd} --help shows examples and options`, async () => {
110
+ const output = await run(`${cmd} --help`)
111
+ expect(output).toContain('USAGE')
112
+ expect(output).toContain('EXAMPLES')
113
+ expect(output).toContain('OPTIONS')
114
+ expect(output).toContain(`mdcontext ${cmd}`)
115
+ expect(output).not.toContain('A true or false value')
116
+ expect(output).not.toContain('This setting is optional')
117
+ })
118
+ }
119
+
120
+ it('index help shows embedding and watch options', async () => {
121
+ const output = await run('index --help')
122
+ expect(output).toContain('--embed')
123
+ expect(output).toContain('--watch')
124
+ expect(output).toContain('--force')
125
+ })
126
+
127
+ it('search help shows keyword and limit options', async () => {
128
+ const output = await run('search --help')
129
+ expect(output).toContain('--keyword')
130
+ expect(output).toContain('--limit')
131
+ expect(output).toContain('--threshold')
132
+ })
133
+
134
+ it('context help shows token budget option', async () => {
135
+ const output = await run('context --help')
136
+ expect(output).toContain('--tokens')
137
+ expect(output).toContain('--brief')
138
+ expect(output).toContain('--full')
139
+ })
140
+
141
+ it('shows notes section when relevant', async () => {
142
+ const indexHelp = await run('index --help')
143
+ expect(indexHelp).toContain('NOTES')
144
+ expect(indexHelp).toContain('.mdcontext')
145
+
146
+ const searchHelp = await run('search --help')
147
+ expect(searchHelp).toContain('NOTES')
148
+ expect(searchHelp).toContain('semantic')
149
+ })
150
+ })
151
+
152
+ describe('tree command', () => {
153
+ it('lists markdown files in directory', async () => {
154
+ const output = await run('tree')
155
+ expect(output).toContain('Markdown files')
156
+ expect(output).toContain('.md')
157
+ expect(output).toContain('Total:')
158
+ })
159
+
160
+ it('shows document outline for single file', async () => {
161
+ const output = await run('tree README.md')
162
+ expect(output).toContain('# ')
163
+ expect(output).toContain('tokens')
164
+ expect(output).toContain('##')
165
+ })
166
+
167
+ it('defaults to current directory', async () => {
168
+ const output = await run('tree')
169
+ expect(output).toContain('Markdown files')
170
+ })
171
+ })
172
+
173
+ describe('search command', () => {
174
+ it('performs keyword search with -k flag', async () => {
175
+ const output = await run('search -k "getting started"')
176
+ expect(output).toContain('[keyword]')
177
+ expect(output).toContain('Results:')
178
+ })
179
+
180
+ it.skipIf(!INCLUDE_EMBED_TESTS)('handles no results gracefully', async () => {
181
+ const output = await run('search "xyznonexistent123"')
182
+ expect(output).toContain('Results: 0')
183
+ })
184
+
185
+ it('supports -k flag for explicit keyword search', async () => {
186
+ const output = await run('search -k "API Reference"')
187
+ expect(output).toContain('Content search')
188
+ })
189
+
190
+ it.skipIf(!INCLUDE_EMBED_TESTS)('supports -n flag to limit results', async () => {
191
+ const output = await run('search -n 2 "the"')
192
+ const lines = output
193
+ .split('\n')
194
+ .filter((l) => l.trim().match(/^\w+.*\.md/))
195
+ expect(lines.length).toBeLessThanOrEqual(2)
196
+ })
197
+
198
+ it('shows mode indicator in output', async () => {
199
+ const output = await run('search -k "getting started"')
200
+ expect(output).toContain('[keyword]')
201
+ })
202
+
203
+ it.skipIf(!INCLUDE_EMBED_TESTS)('supports boolean AND operator', async () => {
204
+ const output = await run('search "test AND fixture"')
205
+ expect(output).toContain('Results:')
206
+ })
207
+
208
+ it.skipIf(!INCLUDE_EMBED_TESTS)('supports boolean OR operator', async () => {
209
+ const output = await run('search "installation OR endpoints"')
210
+ expect(output).toContain('Results:')
211
+ })
212
+
213
+ it.skipIf(!INCLUDE_EMBED_TESTS)('supports boolean NOT operator', async () => {
214
+ const output = await run('search "test NOT endpoints"')
215
+ expect(output).toContain('Results:')
216
+ })
217
+
218
+ it.skipIf(!INCLUDE_EMBED_TESTS)('supports quoted phrase search', async () => {
219
+ const output = await run('search \'"Getting Started"\' .')
220
+ expect(output).toContain('Results:')
221
+ })
222
+
223
+ it('supports --mode flag', async () => {
224
+ const output = await run('search --mode keyword "getting started"')
225
+ expect(output).toContain('[keyword]')
226
+ })
227
+
228
+ it.skipIf(!INCLUDE_EMBED_TESTS)(
229
+ 'performs semantic search when embeddings exist',
230
+ async () => {
231
+ const output = await run('search --mode semantic "getting started"')
232
+ expect(output).toContain('[semantic]')
233
+ },
234
+ )
235
+ })
236
+
237
+ describe('context command', () => {
238
+ it('summarizes single file', async () => {
239
+ const output = await run('context README.md')
240
+ expect(output).toContain('# ')
241
+ expect(output).toContain('Tokens:')
242
+ })
243
+
244
+ it.skipIf(!INCLUDE_EMBED_TESTS)('summarizes multiple files', async () => {
245
+ const output = await run('context ./README.md ./getting-started.md')
246
+ expect(output).toContain('Context Assembly')
247
+ expect(output).toContain('Sources: 2')
248
+ })
249
+
250
+ it('shows accurate token count with -t flag', async () => {
251
+ const output = await run('context -t 200 README.md')
252
+ expect(output).toContain('Tokens:')
253
+ })
254
+
255
+ it('supports --brief flag', async () => {
256
+ const brief = await run('context --brief README.md')
257
+ const full = await run('context README.md')
258
+ expect(brief.length).toBeLessThanOrEqual(full.length)
259
+ })
260
+
261
+ it('supports --sections flag to list available sections', async () => {
262
+ const output = await run('context README.md --sections')
263
+ expect(output).toContain('Available sections:')
264
+ expect(output).toContain('tokens')
265
+ })
266
+
267
+ it('supports --section flag to extract specific section', async () => {
268
+ const output = await run('context README.md --section "1"')
269
+ expect(output).toContain('Sections:')
270
+ expect(output).toContain('#')
271
+ })
272
+
273
+ it('supports --sections with --json output', async () => {
274
+ const output = await run('context README.md --sections --json')
275
+ const parsed = JSON.parse(output)
276
+ expect(parsed.sections).toBeDefined()
277
+ expect(Array.isArray(parsed.sections)).toBe(true)
278
+ expect(parsed.sections[0]).toHaveProperty('number')
279
+ expect(parsed.sections[0]).toHaveProperty('heading')
280
+ expect(parsed.sections[0]).toHaveProperty('tokens')
281
+ })
282
+
283
+ it('supports --full flag to disable truncation', async () => {
284
+ const output = await run('context README.md --full')
285
+ expect(output).not.toContain('Truncated')
286
+ })
287
+ })
288
+
289
+ describe('search command context lines', () => {
290
+ it('supports -C flag for context lines', async () => {
291
+ const output = await run('search -k "test" . -C 2')
292
+ expect(output).toContain('[keyword]')
293
+ })
294
+
295
+ it.skipIf(!INCLUDE_EMBED_TESTS)('supports -B and -A flags for asymmetric context', async () => {
296
+ const output = await run('search "test" . -B 1 -A 3')
297
+ expect(output).toContain('[semantic]')
298
+ })
299
+
300
+ it('includes contextLines in JSON output', async () => {
301
+ const output = await run('search -k "test" . -C 2 --json -n 1')
302
+ const parsed = JSON.parse(output)
303
+ expect(parsed.contextBefore).toBe(2)
304
+ expect(parsed.contextAfter).toBe(2)
305
+ if (parsed.results.length > 0 && parsed.results[0].matches) {
306
+ expect(parsed.results[0].matches[0]).toHaveProperty('contextLines')
307
+ }
308
+ })
309
+ })
310
+
311
+ describe('links command', () => {
312
+ it('shows outgoing links from file', async () => {
313
+ const output = await run('links README.md')
314
+ expect(output).toContain('Outgoing links')
315
+ expect(output).toContain('Total:')
316
+ })
317
+ })
318
+
319
+ describe('backlinks command', () => {
320
+ it('shows incoming links to file', async () => {
321
+ const output = await run('backlinks getting-started.md')
322
+ expect(output).toContain('Incoming links')
323
+ expect(output).toContain('Total:')
324
+ })
325
+ })
326
+
327
+ describe('stats command', () => {
328
+ it('shows index statistics', async () => {
329
+ const output = await run('stats')
330
+ expect(output.length).toBeGreaterThan(0)
331
+ })
332
+ })
333
+
334
+ describe('error handling', () => {
335
+ it('handles non-existent file gracefully', async () => {
336
+ const output = await run('tree nonexistent-file-xyz.md', {
337
+ expectError: true,
338
+ })
339
+ expect(output.toLowerCase()).toMatch(/error|not found|no such/i)
340
+ })
341
+
342
+ it('handles non-existent directory gracefully', async () => {
343
+ const output = await run('tree nonexistent-dir-xyz/', {
344
+ expectError: true,
345
+ })
346
+ expect(output.toLowerCase()).toMatch(/error|not found|no such/i)
347
+ })
348
+ })
349
+
350
+ describe('unknown flag handling', () => {
351
+ it('shows clear error for unknown flag', async () => {
352
+ const output = await run('context -x README.md', { expectError: true })
353
+ expect(output).toContain("Unknown option '-x' for 'context'")
354
+ expect(output).toContain('Valid options for')
355
+ })
356
+
357
+ it('suggests typo correction for --jsno', async () => {
358
+ const output = await run('context --jsno README.md', {
359
+ expectError: true,
360
+ })
361
+ expect(output).toContain("Unknown option '--jsno' for 'context'")
362
+ expect(output).toContain("Did you mean '--json'?")
363
+ })
364
+
365
+ it('suggests typo correction for --limt', async () => {
366
+ const output = await run('search --limt 5 "test" .', {
367
+ expectError: true,
368
+ })
369
+ expect(output).toContain("Unknown option '--limt' for 'search'")
370
+ expect(output).toContain("Did you mean '--limit'?")
371
+ })
372
+
373
+ it('lists valid options in error message', async () => {
374
+ const output = await run('context --invalid README.md', {
375
+ expectError: true,
376
+ })
377
+ expect(output).toContain('--tokens')
378
+ expect(output).toContain('--brief')
379
+ expect(output).toContain('--json')
380
+ })
381
+
382
+ it('handles unknown flag with value', async () => {
383
+ const output = await run('context --foo=bar README.md', {
384
+ expectError: true,
385
+ })
386
+ expect(output).toContain("Unknown option '--foo'")
387
+ })
388
+
389
+ it('reports first unknown flag only', async () => {
390
+ const output = await run('context --foo --bar README.md', {
391
+ expectError: true,
392
+ })
393
+ expect(output).toContain("Unknown option '--foo'")
394
+ })
395
+ })
396
+
397
+ describe('flexible flag positioning', () => {
398
+ it('search: allows query before flags', async () => {
399
+ const output = await run('search -k "getting started" -n 2 .')
400
+ expect(output).toContain('Content search')
401
+ expect(output).toContain('Results:')
402
+ })
403
+
404
+ it('search: allows path after flags', async () => {
405
+ const output = await run('search -k "API Reference" .')
406
+ expect(output).toContain('Content search')
407
+ })
408
+
409
+ it('context: allows files before flags', async () => {
410
+ const output = await run('context README.md --brief')
411
+ expect(output).toContain('# ')
412
+ })
413
+
414
+ it('context: allows -t flag after file', async () => {
415
+ const output = await run('context README.md -t 500')
416
+ expect(output).toContain('Tokens:')
417
+ })
418
+
419
+ it('tree: allows path before --json flag', async () => {
420
+ const output = await run('tree . --json')
421
+ expect(output).toContain('[')
422
+ expect(output).toContain('relativePath')
423
+ })
424
+
425
+ it('search: handles --limit=value syntax', async () => {
426
+ const output = await run('search -k "getting started" --limit=2 .')
427
+ expect(output).toContain('Content search')
428
+ })
429
+ })
430
+ })
@@ -0,0 +1,54 @@
1
+ /**
2
+ * BACKLINKS Command
3
+ *
4
+ * Show what links to a file (incoming links).
5
+ */
6
+
7
+ import * as path from 'node:path'
8
+ import { Args, Command, Options } from '@effect/cli'
9
+ import { Console, Effect } from 'effect'
10
+ import { getIncomingLinks } from '../../index/indexer.js'
11
+ import { jsonOption, prettyOption } from '../options.js'
12
+ import { formatJson } from '../utils.js'
13
+
14
+ export const backlinksCommand = Command.make(
15
+ 'backlinks',
16
+ {
17
+ file: Args.file({ name: 'file' }).pipe(
18
+ Args.withDescription('Markdown file to find references to'),
19
+ ),
20
+ root: Options.directory('root').pipe(
21
+ Options.withAlias('r'),
22
+ Options.withDescription('Root directory for resolving relative links'),
23
+ Options.withDefault('.'),
24
+ ),
25
+ json: jsonOption,
26
+ pretty: prettyOption,
27
+ },
28
+ ({ file, root, json, pretty }) =>
29
+ Effect.gen(function* () {
30
+ const resolvedRoot = path.resolve(root)
31
+ const resolvedFile = path.resolve(file)
32
+ const relativePath = path.relative(resolvedRoot, resolvedFile)
33
+
34
+ const links = yield* getIncomingLinks(resolvedRoot, resolvedFile)
35
+
36
+ if (json) {
37
+ yield* Console.log(
38
+ formatJson({ file: relativePath, backlinks: links }, pretty),
39
+ )
40
+ } else {
41
+ yield* Console.log(`Incoming links to ${relativePath}:`)
42
+ yield* Console.log('')
43
+ if (links.length === 0) {
44
+ yield* Console.log(' (none)')
45
+ } else {
46
+ for (const link of links) {
47
+ yield* Console.log(` <- ${link}`)
48
+ }
49
+ }
50
+ yield* Console.log('')
51
+ yield* Console.log(`Total: ${links.length} backlinks`)
52
+ }
53
+ }),
54
+ ).pipe(Command.withDescription('What links to this?'))
@@ -0,0 +1,197 @@
1
+ /**
2
+ * CONTEXT Command
3
+ *
4
+ * Get LLM-ready summary of markdown files.
5
+ */
6
+
7
+ import * as path from 'node:path'
8
+ import { Args, Command, Options } from '@effect/cli'
9
+ import { Console, Effect } from 'effect'
10
+ import { parseFile } from '../../parser/parser.js'
11
+ import {
12
+ buildSectionList,
13
+ extractSectionContent,
14
+ formatExtractedSections,
15
+ formatSectionList,
16
+ } from '../../parser/section-filter.js'
17
+ import {
18
+ assembleContext,
19
+ formatAssembledContext,
20
+ formatSummary,
21
+ summarizeFile,
22
+ } from '../../summarize/summarizer.js'
23
+ import { jsonOption, prettyOption } from '../options.js'
24
+ import { formatJson } from '../utils.js'
25
+
26
+ export const contextCommand = Command.make(
27
+ 'context',
28
+ {
29
+ files: Args.file({ name: 'files' }).pipe(
30
+ Args.withDescription('Markdown file(s) to summarize'),
31
+ Args.repeated,
32
+ ),
33
+ tokens: Options.integer('tokens').pipe(
34
+ Options.withAlias('t'),
35
+ Options.withDescription('Token budget'),
36
+ Options.withDefault(2000),
37
+ ),
38
+ brief: Options.boolean('brief').pipe(
39
+ Options.withDescription('Minimal output'),
40
+ Options.withDefault(false),
41
+ ),
42
+ full: Options.boolean('full').pipe(
43
+ Options.withDescription('Include full content'),
44
+ Options.withDefault(false),
45
+ ),
46
+ section: Options.text('section').pipe(
47
+ Options.withAlias('S'),
48
+ Options.withDescription(
49
+ 'Filter by section name, number (e.g., "5.3"), or glob pattern (e.g., "Memory*")',
50
+ ),
51
+ Options.optional,
52
+ ),
53
+ sections: Options.boolean('sections').pipe(
54
+ Options.withDescription('List available sections'),
55
+ Options.withDefault(false),
56
+ ),
57
+ shallow: Options.boolean('shallow').pipe(
58
+ Options.withDescription('Exclude nested subsections when filtering'),
59
+ Options.withDefault(false),
60
+ ),
61
+ json: jsonOption,
62
+ pretty: prettyOption,
63
+ },
64
+ ({ files, tokens, brief, full, section, sections, shallow, json, pretty }) =>
65
+ Effect.gen(function* () {
66
+ // Effect CLI Args.repeated returns an array
67
+ const fileList: string[] = Array.isArray(files) ? files : []
68
+
69
+ if (fileList.length === 0) {
70
+ yield* Effect.fail(
71
+ new Error(
72
+ 'At least one file is required. Usage: mdcontext context <file> [files...]',
73
+ ),
74
+ )
75
+ }
76
+
77
+ // Get section option value (it's an Option type)
78
+ const sectionSelector =
79
+ section._tag === 'Some' ? section.value : undefined
80
+
81
+ // Handle --sections flag: list available sections
82
+ if (sections) {
83
+ for (const file of fileList) {
84
+ const filePath = path.resolve(file)
85
+ const document = yield* parseFile(filePath).pipe(
86
+ Effect.mapError((e) => new Error(`${e._tag}: ${e.message}`)),
87
+ )
88
+
89
+ const sectionList = buildSectionList(document)
90
+
91
+ if (json) {
92
+ const output = {
93
+ path: filePath,
94
+ title: document.title,
95
+ sections: sectionList.map((s) => ({
96
+ number: s.number,
97
+ heading: s.heading,
98
+ level: s.level,
99
+ tokens: s.tokenCount,
100
+ })),
101
+ }
102
+ yield* Console.log(formatJson(output, pretty))
103
+ } else {
104
+ yield* Console.log(`# ${document.title}`)
105
+ yield* Console.log(`Path: ${filePath}`)
106
+ yield* Console.log('')
107
+ yield* Console.log('Available sections:')
108
+ yield* Console.log(formatSectionList(sectionList))
109
+ }
110
+ }
111
+ return
112
+ }
113
+
114
+ // Handle --section flag: extract specific section(s)
115
+ if (sectionSelector) {
116
+ for (const file of fileList) {
117
+ const filePath = path.resolve(file)
118
+ const document = yield* parseFile(filePath).pipe(
119
+ Effect.mapError((e) => new Error(`${e._tag}: ${e.message}`)),
120
+ )
121
+
122
+ const { sections: extractedSections, matchedNumbers } =
123
+ extractSectionContent(document, sectionSelector, { shallow })
124
+
125
+ if (extractedSections.length === 0) {
126
+ yield* Console.error(
127
+ `No sections found matching "${sectionSelector}" in ${file}`,
128
+ )
129
+ yield* Console.error('Use --sections to list available sections.')
130
+ continue
131
+ }
132
+
133
+ if (json) {
134
+ const output = {
135
+ path: filePath,
136
+ title: document.title,
137
+ selector: sectionSelector,
138
+ shallow,
139
+ matchedSections: matchedNumbers,
140
+ content: formatExtractedSections(extractedSections),
141
+ sections: extractedSections.map((s) => ({
142
+ heading: s.heading,
143
+ level: s.level,
144
+ tokens: s.metadata.tokenCount,
145
+ })),
146
+ }
147
+ yield* Console.log(formatJson(output, pretty))
148
+ } else {
149
+ yield* Console.log(`# ${document.title}`)
150
+ yield* Console.log(`Path: ${filePath}`)
151
+ yield* Console.log(`Sections: ${matchedNumbers.join(', ')}`)
152
+ yield* Console.log('')
153
+ yield* Console.log(formatExtractedSections(extractedSections))
154
+ }
155
+ }
156
+ return
157
+ }
158
+
159
+ // Determine level
160
+ const level = full ? 'full' : brief ? 'brief' : 'summary'
161
+
162
+ // When --full is specified, disable token truncation
163
+ const effectiveMaxTokens = full ? undefined : tokens
164
+
165
+ const firstFile = fileList[0]
166
+ if (fileList.length === 1 && firstFile) {
167
+ // Single file: use summarizeFile
168
+ const filePath = path.resolve(firstFile)
169
+ const summary = yield* summarizeFile(filePath, {
170
+ level: level as 'brief' | 'summary' | 'full',
171
+ maxTokens: effectiveMaxTokens,
172
+ })
173
+
174
+ if (json) {
175
+ yield* Console.log(formatJson(summary, pretty))
176
+ } else {
177
+ yield* Console.log(
178
+ formatSummary(summary, { maxTokens: effectiveMaxTokens }),
179
+ )
180
+ }
181
+ } else {
182
+ // Multiple files: use assembleContext
183
+ // Note: assembleContext always requires a budget; use large number for --full
184
+ const root = process.cwd()
185
+ const assembled = yield* assembleContext(root, fileList, {
186
+ budget: full ? Number.MAX_SAFE_INTEGER : tokens,
187
+ level: level as 'brief' | 'summary' | 'full',
188
+ })
189
+
190
+ if (json) {
191
+ yield* Console.log(formatJson(assembled, pretty))
192
+ } else {
193
+ yield* Console.log(formatAssembledContext(assembled))
194
+ }
195
+ }
196
+ }),
197
+ ).pipe(Command.withDescription('Get LLM-ready summary'))