contentbase 0.0.1 → 0.0.4

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 (97) hide show
  1. package/.mcp.json +9 -0
  2. package/CLI.md +593 -0
  3. package/MCP-DESCRIPTIONS-REVIEW.md +122 -0
  4. package/MCP-SERVER-SPEC.md +453 -0
  5. package/PRIMER.md +540 -0
  6. package/README.md +406 -13
  7. package/bun.lock +45 -6
  8. package/dist/cnotes +0 -0
  9. package/docs/README.md +110 -0
  10. package/docs/TABLE-OF-CONTENTS.md +7 -0
  11. package/docs/models.ts +38 -0
  12. package/models.ts +38 -0
  13. package/package.json +14 -4
  14. package/public/web-demo/index.html +813 -0
  15. package/scripts/examples/01-collection-setup.ts +46 -0
  16. package/scripts/examples/02-querying.ts +67 -0
  17. package/scripts/examples/03-sections.ts +36 -0
  18. package/scripts/examples/04-relationships.ts +54 -0
  19. package/scripts/examples/05-document-api.ts +54 -0
  20. package/scripts/examples/06-extract-sections.ts +55 -0
  21. package/scripts/examples/07-validation.ts +46 -0
  22. package/scripts/examples/08-serialization.ts +51 -0
  23. package/scripts/examples/lib/format.ts +87 -0
  24. package/scripts/examples/lib/setup.ts +21 -0
  25. package/scripts/examples/run-all.ts +43 -0
  26. package/showcases/node_modules/.cache/.repl_history +3 -0
  27. package/showcases/vinyl-collection/artists/nina-simone.mdx +1 -1
  28. package/src/__tests__/semantic-search.integration.test.ts +284 -0
  29. package/src/api/endpoints/actions.ts +34 -0
  30. package/src/api/endpoints/doc.ts +187 -0
  31. package/src/api/endpoints/docs-index.ts +26 -0
  32. package/src/api/endpoints/document.ts +171 -0
  33. package/src/api/endpoints/documents.ts +71 -0
  34. package/src/api/endpoints/inspect.ts +16 -0
  35. package/src/api/endpoints/models.ts +10 -0
  36. package/src/api/endpoints/query.ts +88 -0
  37. package/src/api/endpoints/root.ts +7 -0
  38. package/src/api/endpoints/search-reindex.ts +65 -0
  39. package/src/api/endpoints/search-status.ts +55 -0
  40. package/src/api/endpoints/search.ts +104 -0
  41. package/src/api/endpoints/semantic-search.ts +120 -0
  42. package/src/api/endpoints/text-search.ts +63 -0
  43. package/src/api/endpoints/validate.ts +34 -0
  44. package/src/api/helpers.ts +97 -0
  45. package/src/base-model.ts +12 -0
  46. package/src/cli/commands/action.ts +82 -44
  47. package/src/cli/commands/console.ts +124 -0
  48. package/src/cli/commands/create.ts +179 -53
  49. package/src/cli/commands/embed.ts +323 -0
  50. package/src/cli/commands/export.ts +58 -24
  51. package/src/cli/commands/extract.ts +174 -0
  52. package/src/cli/commands/help.ts +81 -0
  53. package/src/cli/commands/index.ts +17 -0
  54. package/src/cli/commands/init.ts +72 -48
  55. package/src/cli/commands/inspect.ts +56 -46
  56. package/src/cli/commands/mcp.ts +1219 -0
  57. package/src/cli/commands/search.ts +285 -0
  58. package/src/cli/commands/serve.ts +348 -0
  59. package/src/cli/commands/summary.ts +60 -0
  60. package/src/cli/commands/teach.ts +86 -0
  61. package/src/cli/commands/text-search.ts +134 -0
  62. package/src/cli/commands/validate.ts +126 -64
  63. package/src/cli/index.ts +88 -19
  64. package/src/cli/load-collection.ts +144 -17
  65. package/src/cli/registry.ts +28 -0
  66. package/src/collection.ts +455 -6
  67. package/src/define-model.ts +104 -7
  68. package/src/document.ts +47 -1
  69. package/src/extract-sections.ts +222 -0
  70. package/src/index.ts +20 -2
  71. package/src/model-instance.ts +35 -5
  72. package/src/node-shortcuts.ts +1 -1
  73. package/src/query/collection-query.ts +96 -9
  74. package/src/query/index.ts +7 -0
  75. package/src/query/query-dsl.ts +259 -0
  76. package/src/relationships/has-many.ts +39 -0
  77. package/src/relationships/index.ts +7 -2
  78. package/src/section.ts +2 -0
  79. package/src/types.ts +24 -2
  80. package/src/utils/index.ts +1 -0
  81. package/src/utils/match-pattern.ts +65 -0
  82. package/src/validator.ts +18 -1
  83. package/test/collection.test.ts +118 -2
  84. package/test/extract-sections.test.ts +356 -0
  85. package/test/fixtures/sdlc/MODELS.md +106 -0
  86. package/test/fixtures/sdlc/SKILL.md +404 -0
  87. package/test/fixtures/sdlc/index.ts +9 -0
  88. package/test/fixtures/sdlc/models.ts +8 -6
  89. package/test/fixtures/sdlc/stories/authentication/a-user-should-be-able-to-login.mdx +20 -0
  90. package/test/fixtures/sdlc/templates/epic.md +23 -0
  91. package/test/fixtures/sdlc/templates/story.md +19 -0
  92. package/test/pattern.test.ts +191 -0
  93. package/test/query-dsl.test.ts +431 -0
  94. package/test/query.test.ts +29 -0
  95. package/test/relationships.test.ts +130 -0
  96. package/test/section.test.ts +65 -4
  97. package/test/table-of-contents.test.ts +135 -0
@@ -0,0 +1,323 @@
1
+ import { z } from 'zod'
2
+ import { existsSync, readdirSync, accessSync, constants as fsConstants } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { commands } from '../registry.js'
5
+ import { loadCollection } from '../load-collection.js'
6
+
7
+ const argsSchema = z.object({
8
+ force: z.boolean().default(false),
9
+ provider: z.enum(['local', 'openai']).default('openai'),
10
+ status: z.boolean().default(false),
11
+ local: z.boolean().default(false),
12
+ installLocal: z.boolean().default(false),
13
+ contentFolder: z.string().optional(),
14
+ })
15
+
16
+ function hasSearchIndex(rootPath: string): boolean {
17
+ const dbDir = path.join(rootPath, '.contentbase')
18
+ if (!existsSync(dbDir)) return false
19
+ try {
20
+ const files = readdirSync(dbDir) as string[]
21
+ return files.some((f: string) => f.startsWith('search.') && f.endsWith('.sqlite'))
22
+ } catch {
23
+ return false
24
+ }
25
+ }
26
+
27
+ function collectDocumentInputs(collection: any) {
28
+ const inputs: any[] = []
29
+ for (const pathId of collection.available) {
30
+ const doc = collection.document(pathId)
31
+ const modelDef = collection.findModelDefinition(pathId)
32
+
33
+ const sections: any[] = []
34
+ const lines = (doc.content as string).split('\n')
35
+ let currentHeading: string | null = null
36
+ let currentContent: string[] = []
37
+
38
+ for (const line of lines) {
39
+ const h2Match = line.match(/^## (.+)/)
40
+ if (h2Match) {
41
+ if (currentHeading) {
42
+ sections.push({
43
+ heading: currentHeading,
44
+ headingPath: currentHeading,
45
+ content: currentContent.join('\n').trim(),
46
+ level: 2,
47
+ })
48
+ }
49
+ currentHeading = h2Match[1].trim()
50
+ currentContent = []
51
+ } else if (currentHeading) {
52
+ currentContent.push(line)
53
+ }
54
+ }
55
+ if (currentHeading) {
56
+ sections.push({
57
+ heading: currentHeading,
58
+ headingPath: currentHeading,
59
+ content: currentContent.join('\n').trim(),
60
+ level: 2,
61
+ })
62
+ }
63
+
64
+ inputs.push({
65
+ pathId,
66
+ model: modelDef?.name ?? undefined,
67
+ title: doc.title,
68
+ slug: doc.slug,
69
+ meta: doc.meta,
70
+ content: doc.content,
71
+ sections: sections.length > 0 ? sections : undefined,
72
+ })
73
+ }
74
+ return inputs
75
+ }
76
+
77
+ function isLocalInstalled(): boolean {
78
+ const modulePath = path.join(process.cwd(), 'node_modules', 'node-llama-cpp')
79
+ try {
80
+ return existsSync(modulePath)
81
+ } catch {
82
+ return false
83
+ }
84
+ }
85
+
86
+ async function installLocal(SemanticSearch: any, container: any): Promise<void> {
87
+ const cwd = process.cwd()
88
+ const nodeModules = path.join(cwd, 'node_modules')
89
+
90
+ // Check permissions
91
+ try {
92
+ if (existsSync(nodeModules)) {
93
+ accessSync(nodeModules, fsConstants.W_OK)
94
+ }
95
+ } catch {
96
+ const version = SemanticSearch.PINNED_LLAMA_VERSION
97
+ const cmd = detectInstallCommand(cwd, version)
98
+ console.error(`Cannot write to ${nodeModules}.`)
99
+ console.error(`Run manually with elevated permissions:\n ${cmd}`)
100
+ process.exit(1)
101
+ }
102
+
103
+ console.error(`Installing node-llama-cpp@${SemanticSearch.PINNED_LLAMA_VERSION}...`)
104
+
105
+ // Create a temporary instance to use the install method
106
+ if (!container.features.available.includes('semanticSearch')) {
107
+ SemanticSearch.attach(container)
108
+ }
109
+ const ss = container.feature('semanticSearch', {
110
+ dbPath: path.join(cwd, '.contentbase/search.sqlite'),
111
+ embeddingProvider: 'local',
112
+ })
113
+
114
+ try {
115
+ await ss.installLocalEmbeddings(cwd)
116
+ console.log(`node-llama-cpp@${SemanticSearch.PINNED_LLAMA_VERSION} installed successfully.`)
117
+ } catch (err: any) {
118
+ const msg = err?.message ?? String(err)
119
+ if (msg.includes('network') || msg.includes('ETIMEDOUT') || msg.includes('ENOTFOUND')) {
120
+ console.error(`Install failed (network unreachable). To use local embeddings offline, manually install node-llama-cpp@${SemanticSearch.PINNED_LLAMA_VERSION} into this project.`)
121
+ } else {
122
+ console.error(msg)
123
+ }
124
+ process.exit(1)
125
+ }
126
+ }
127
+
128
+ function detectInstallCommand(cwd: string, version: string): string {
129
+ const pkg = `node-llama-cpp@${version}`
130
+ if (existsSync(path.join(cwd, 'bun.lockb')) || existsSync(path.join(cwd, 'bun.lock'))) {
131
+ return `bun add --optional ${pkg}`
132
+ } else if (existsSync(path.join(cwd, 'pnpm-lock.yaml'))) {
133
+ return `pnpm add --save-optional ${pkg}`
134
+ } else if (existsSync(path.join(cwd, 'yarn.lock'))) {
135
+ return `yarn add --optional ${pkg}`
136
+ }
137
+ return `npm install --save-optional ${pkg}`
138
+ }
139
+
140
+ async function handler(options: z.infer<typeof argsSchema>, { container }: { container: any }) {
141
+ const { SemanticSearch } = await import('@soederpop/luca/agi')
142
+
143
+ // --install-local: install node-llama-cpp only, then exit
144
+ if (options.installLocal) {
145
+ if (isLocalInstalled()) {
146
+ console.log('node-llama-cpp is already installed. Skipping.')
147
+ return
148
+ }
149
+ await installLocal(SemanticSearch, container)
150
+ return
151
+ }
152
+
153
+ // Resolve effective provider: --local flag overrides --provider
154
+ const provider = options.local ? 'local' : options.provider
155
+
156
+ // --local: auto-install if needed
157
+ if (options.local && !isLocalInstalled()) {
158
+ await installLocal(SemanticSearch, container)
159
+ }
160
+
161
+ const collection = await loadCollection({
162
+ contentFolder: options.contentFolder,
163
+ })
164
+
165
+ // --status: show index health without modifying anything
166
+ if (options.status) {
167
+ if (!hasSearchIndex(collection.rootPath)) {
168
+ console.log('No search index found.')
169
+ console.log(`Documents in collection: ${collection.available.length}`)
170
+ console.log(`\nRun: cnotes embed`)
171
+ return
172
+ }
173
+
174
+ if (!container.features.available.includes('semanticSearch')) {
175
+ SemanticSearch.attach(container)
176
+ }
177
+ const dbPath = path.join(collection.rootPath, '.contentbase/search.sqlite')
178
+ const ss = container.feature('semanticSearch', {
179
+ dbPath,
180
+ embeddingProvider: provider,
181
+ })
182
+ await ss.initDb()
183
+
184
+ const stats = ss.getStats()
185
+ console.log('Search Index Status')
186
+ console.log('-------------------')
187
+ console.log(`Documents indexed: ${stats.documentCount}`)
188
+ console.log(`Chunks created: ${stats.chunkCount}`)
189
+ console.log(`Embeddings stored: ${stats.embeddingCount}`)
190
+ console.log(`Last indexed: ${stats.lastIndexedAt ?? 'never'}`)
191
+ console.log(`Provider: ${stats.provider}`)
192
+ console.log(`Model: ${stats.model}`)
193
+ console.log(`Dimensions: ${stats.dimensions}`)
194
+ console.log(`Database size: ${formatBytes(stats.dbSizeBytes)}`)
195
+ console.log(`Collection docs: ${collection.available.length}`)
196
+
197
+ // Check for staleness
198
+ const docs = collectDocumentInputs(collection)
199
+ const stale = docs.filter((d: any) => ss.needsReindex(d)).length
200
+ if (stale > 0) {
201
+ console.log(`Stale documents: ${stale}`)
202
+ } else {
203
+ console.log(`Status: up to date`)
204
+ }
205
+ return
206
+ }
207
+
208
+ // Embed / re-embed
209
+ if (!container.features.available.includes('semanticSearch')) {
210
+ SemanticSearch.attach(container)
211
+ }
212
+ const dbPath = path.join(collection.rootPath, '.contentbase/search.sqlite')
213
+ const ss = container.feature('semanticSearch', {
214
+ dbPath,
215
+ embeddingProvider: provider,
216
+ })
217
+ await ss.initDb()
218
+
219
+ const docs = collectDocumentInputs(collection)
220
+ const startTime = Date.now()
221
+
222
+ if (options.force) {
223
+ console.error('Clearing existing index...')
224
+ await ss.reindex()
225
+ }
226
+
227
+ const toIndex = options.force ? docs : docs.filter((d: any) => ss.needsReindex(d))
228
+
229
+ // Remove stale
230
+ ss.removeStale(docs.map((d: any) => d.pathId))
231
+
232
+ if (toIndex.length === 0) {
233
+ console.log('All documents are up to date. Nothing to embed.')
234
+ const stats = ss.getStats()
235
+ printStats(stats, 0, Date.now() - startTime)
236
+ return
237
+ }
238
+
239
+ console.error(`Embedding ${toIndex.length} of ${docs.length} document(s)...`)
240
+ console.error(`Provider: ${provider}`)
241
+
242
+ const batchSize = 5
243
+
244
+ for (let i = 0; i < toIndex.length; i += batchSize) {
245
+ const batch = toIndex.slice(i, i + batchSize)
246
+ await ss.indexDocuments(batch)
247
+ const done = Math.min(i + batchSize, toIndex.length)
248
+ const pct = Math.round((done / toIndex.length) * 100)
249
+ const elapsed = Date.now() - startTime
250
+ const eta = toIndex.length > done
251
+ ? Math.round((elapsed / done) * (toIndex.length - done) / 1000)
252
+ : 0
253
+ const etaStr = eta > 0 ? ` ETA: ${eta}s` : ''
254
+ console.error(` [${pct}%] ${done}/${toIndex.length} documents${etaStr}`)
255
+ }
256
+
257
+ const elapsed = Date.now() - startTime
258
+ const stats = ss.getStats()
259
+ printStats(stats, toIndex.length, elapsed)
260
+ }
261
+
262
+ function printStats(stats: any, indexed: number, elapsedMs: number) {
263
+ console.log()
264
+ console.log('Embedding complete.')
265
+ if (indexed > 0) console.log(` Documents indexed: ${indexed}`)
266
+ console.log(` Total documents: ${stats.documentCount}`)
267
+ console.log(` Total chunks: ${stats.chunkCount}`)
268
+ console.log(` Embeddings: ${stats.embeddingCount}`)
269
+ console.log(` Time elapsed: ${(elapsedMs / 1000).toFixed(1)}s`)
270
+ console.log(` Database size: ${formatBytes(stats.dbSizeBytes)}`)
271
+ }
272
+
273
+ function formatBytes(bytes: number): string {
274
+ if (bytes < 1024) return `${bytes} B`
275
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
276
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
277
+ }
278
+
279
+ commands.register('embed', {
280
+ description: 'Generate embeddings for collection documents',
281
+ help: `# cnotes embed
282
+
283
+ Generate or update vector embeddings for all documents in the collection. Required before using \`cnotes search\`.
284
+
285
+ ## Usage
286
+
287
+ \`\`\`
288
+ cnotes embed [options]
289
+ \`\`\`
290
+
291
+ ## Options
292
+
293
+ | Option | Default | Description |
294
+ |--------|---------|-------------|
295
+ | \`--force\` | \`false\` | Re-embed everything (ignore content hashes) |
296
+ | \`--provider\` | \`openai\` | Embedding provider: \`openai\` or \`local\` |
297
+ | \`--status\` | \`false\` | Show index health without embedding |
298
+ | \`--local\` | \`false\` | Use local embeddings; auto-installs node-llama-cpp if not found |
299
+ | \`--install-local\` | \`false\` | Only install node-llama-cpp (no embedding), then exit |
300
+ | \`--contentFolder\` | | Path to content folder |
301
+
302
+ ## Examples
303
+
304
+ \`\`\`bash
305
+ # Generate/update embeddings (uses OpenAI by default)
306
+ cnotes embed
307
+
308
+ # Re-embed everything from scratch
309
+ cnotes embed --force
310
+
311
+ # Use local embeddings (auto-installs node-llama-cpp if needed)
312
+ cnotes embed --local
313
+
314
+ # Only install node-llama-cpp without embedding
315
+ cnotes embed --install-local
316
+
317
+ # Show index health
318
+ cnotes embed --status
319
+ \`\`\`
320
+ `,
321
+ argsSchema,
322
+ handler,
323
+ })
@@ -1,24 +1,58 @@
1
- import { defineCommand } from "citty";
2
- import { loadCollection } from "../load-collection";
3
-
4
- export default defineCommand({
5
- meta: {
6
- name: "export",
7
- description: "Export collection as JSON",
8
- },
9
- args: {
10
- rootPath: {
11
- type: "string",
12
- description: "Root path for the collection",
13
- alias: "r",
14
- },
15
- },
16
- async run({ args }) {
17
- const collection = await loadCollection({
18
- rootPath: args.rootPath as string | undefined,
19
- });
20
-
21
- const data = await collection.export();
22
- console.log(JSON.stringify(data, null, 2));
23
- },
24
- });
1
+ import { z } from 'zod'
2
+ import { commands } from '../registry.js'
3
+ import { loadCollection } from '../load-collection.js'
4
+
5
+ const argsSchema = z.object({
6
+ contentFolder: z.string().optional(),
7
+ })
8
+
9
+ async function handler(options: z.infer<typeof argsSchema>) {
10
+ const collection = await loadCollection({
11
+ contentFolder: options.contentFolder,
12
+ })
13
+
14
+ const data = await collection.export()
15
+ console.log(JSON.stringify(data, null, 2))
16
+ }
17
+
18
+ commands.register('export', {
19
+ description: 'Export collection as JSON',
20
+ help: `# cnotes export
21
+
22
+ Export the entire collection as a JSON object. Each document includes its path ID, title, frontmatter, content, and model info.
23
+
24
+ ## Usage
25
+
26
+ \`\`\`
27
+ cnotes export [options]
28
+ \`\`\`
29
+
30
+ ## Options
31
+
32
+ | Option | Description |
33
+ |--------|-------------|
34
+ | \`--contentFolder\` | Path to content folder |
35
+
36
+ ## Output
37
+
38
+ Writes JSON to stdout. Pipe to a file or another tool.
39
+
40
+ ## Examples
41
+
42
+ \`\`\`bash
43
+ # Export to stdout
44
+ cnotes export
45
+
46
+ # Save to a file
47
+ cnotes export > backup.json
48
+
49
+ # Export a specific content folder
50
+ cnotes export --contentFolder ./docs > docs.json
51
+
52
+ # Pipe to jq for filtering
53
+ cnotes export | jq '.[] | select(.meta.status == "published")'
54
+ \`\`\`
55
+ `,
56
+ argsSchema,
57
+ handler,
58
+ })
@@ -0,0 +1,174 @@
1
+ import { z } from 'zod'
2
+ import picomatch from 'picomatch'
3
+ import yaml from 'js-yaml'
4
+ import { commands } from '../registry.js'
5
+ import { loadCollection } from '../load-collection.js'
6
+ import { stringifyAst } from '../../utils/stringify-ast.js'
7
+ import type { Root, RootContent, Heading } from 'mdast'
8
+
9
+ const argsSchema = z.object({
10
+ sections: z.string().optional(),
11
+ s: z.string().optional(),
12
+ title: z.string().optional(),
13
+ t: z.string().optional(),
14
+ frontmatter: z.boolean().default(false),
15
+ noNormalizeHeadings: z.boolean().default(false),
16
+ contentFolder: z.string().optional(),
17
+ })
18
+
19
+ async function handler(options: z.infer<typeof argsSchema>, context: { container: any }) {
20
+ const collection = await loadCollection({
21
+ contentFolder: options.contentFolder,
22
+ })
23
+
24
+ const target = context.container.argv._[1] as string | undefined
25
+ if (!target) {
26
+ console.error('Usage: cnotes extract <target> --sections "Section1,Section2"')
27
+ process.exit(1)
28
+ }
29
+
30
+ const sectionsArg = options.sections || options.s
31
+ if (!sectionsArg) {
32
+ console.error('--sections is required')
33
+ process.exit(1)
34
+ }
35
+
36
+ const sectionNames = sectionsArg
37
+ .split(',')
38
+ .map((s) => s.trim())
39
+ .filter(Boolean)
40
+ const includeFrontmatter = options.frontmatter
41
+ const noNormalize = options.noNormalizeHeadings
42
+ const title = options.title || options.t
43
+
44
+ const isMatch = picomatch(target)
45
+ const matchingIds = collection.available.filter((id) => isMatch(id))
46
+
47
+ if (matchingIds.length === 0) {
48
+ console.error(`No documents matched: ${target}`)
49
+ process.exit(1)
50
+ }
51
+
52
+ const allNodes: RootContent[] = []
53
+
54
+ if (title) {
55
+ allNodes.push({
56
+ type: 'heading',
57
+ depth: 1,
58
+ children: [{ type: 'text', value: title }],
59
+ } as Heading)
60
+ }
61
+
62
+ for (const id of matchingIds) {
63
+ const doc = collection.document(id)
64
+
65
+ if (includeFrontmatter && Object.keys(doc.meta).length > 0) {
66
+ allNodes.push({
67
+ type: 'yaml',
68
+ value: yaml.dump(doc.meta).trim(),
69
+ } as any)
70
+ }
71
+
72
+ const docNodes: RootContent[] = []
73
+
74
+ const titleNode = doc.nodes.firstHeading
75
+ if (titleNode) {
76
+ docNodes.push(titleNode as RootContent)
77
+ }
78
+
79
+ const leading = doc.nodes.leadingElementsAfterTitle
80
+ if (leading.length > 0) {
81
+ docNodes.push(...(leading as RootContent[]))
82
+ }
83
+
84
+ for (const name of sectionNames) {
85
+ try {
86
+ const sectionNodes = doc.extractSection(name)
87
+ if (sectionNodes.length > 0) {
88
+ docNodes.push(...(sectionNodes as RootContent[]))
89
+ }
90
+ } catch {
91
+ // Section not found — skip
92
+ }
93
+ }
94
+
95
+ if (!noNormalize && docNodes.length > 0) {
96
+ const headings = docNodes.filter(
97
+ (n): n is Heading => n.type === 'heading'
98
+ )
99
+ if (headings.length > 0) {
100
+ const minDepth = Math.min(...headings.map((h) => h.depth))
101
+ const targetDepth = title ? 2 : 1
102
+ const shift = targetDepth - minDepth
103
+ if (shift !== 0) {
104
+ for (const h of headings) {
105
+ h.depth = Math.max(1, Math.min(6, h.depth + shift)) as
106
+ | 1
107
+ | 2
108
+ | 3
109
+ | 4
110
+ | 5
111
+ | 6
112
+ }
113
+ }
114
+ }
115
+ }
116
+
117
+ allNodes.push(...docNodes)
118
+ }
119
+
120
+ const combinedAst: Root = { type: 'root', children: allNodes }
121
+ console.log(stringifyAst(combinedAst).trim())
122
+ }
123
+
124
+ commands.register('extract', {
125
+ description: 'Extract specific sections from documents',
126
+ help: `# cnotes extract
127
+
128
+ Extract specific sections from one or more documents. Supports glob patterns for matching multiple documents and outputs clean markdown with normalized headings.
129
+
130
+ ## Usage
131
+
132
+ \`\`\`
133
+ cnotes extract <target> --sections "Section1,Section2" [options]
134
+ \`\`\`
135
+
136
+ ## Arguments
137
+
138
+ | Argument | Description |
139
+ |----------|-------------|
140
+ | \`target\` | Path ID or glob pattern (e.g. \`epics/*\`, \`tasks/auth-*\`) |
141
+
142
+ ## Options
143
+
144
+ | Option | Alias | Description |
145
+ |--------|-------|-------------|
146
+ | \`--sections\` | \`-s\` | **Required.** Comma-separated section headings to extract |
147
+ | \`--title\` | \`-t\` | Add a top-level H1 title to the output |
148
+ | \`--frontmatter\` | | Include YAML frontmatter in the output |
149
+ | \`--noNormalizeHeadings\` | | Skip heading depth normalization |
150
+ | \`--contentFolder\` | | Path to content folder |
151
+
152
+ ## Heading Normalization
153
+
154
+ By default, extracted headings are re-leveled so the output starts at H1 (or H2 when \`--title\` is used). Use \`--noNormalizeHeadings\` to preserve original depths.
155
+
156
+ ## Examples
157
+
158
+ \`\`\`bash
159
+ # Extract Overview from a single document
160
+ cnotes extract epics/auth-system --sections "Overview"
161
+
162
+ # Extract multiple sections
163
+ cnotes extract tasks/login-bug -s "Overview,Requirements,Acceptance Criteria"
164
+
165
+ # Extract from all epics with a title wrapper
166
+ cnotes extract "epics/*" -s "Overview" -t "Epic Summaries"
167
+
168
+ # Include frontmatter in output
169
+ cnotes extract epics/auth-system -s "Overview" --frontmatter
170
+ \`\`\`
171
+ `,
172
+ argsSchema,
173
+ handler,
174
+ })
@@ -0,0 +1,81 @@
1
+ import { commands } from '../registry.js'
2
+
3
+ async function handler(_options: any, context: { container: any }) {
4
+ const ui = context.container.feature('ui')
5
+
6
+ const lines: string[] = [
7
+ '# cnotes',
8
+ '',
9
+ 'An ORM for structured Markdown/MDX files.',
10
+ '',
11
+ '## Usage',
12
+ '',
13
+ '```',
14
+ 'cnotes <command> [options]',
15
+ '```',
16
+ '',
17
+ '## Commands',
18
+ '',
19
+ '| Command | Description |',
20
+ '|---------|-------------|',
21
+ ]
22
+
23
+ for (const name of commands.available) {
24
+ const def = commands.get(name)!
25
+ lines.push(`| \`${name}\` | ${def.description} |`)
26
+ }
27
+
28
+ lines.push(
29
+ '',
30
+ '## Global Options',
31
+ '',
32
+ '| Option | Description |',
33
+ '|--------|-------------|',
34
+ '| `--help`, `-h` | Show help for a command |',
35
+ '| `--contentFolder` | Path to content folder (most commands) |',
36
+ '',
37
+ '## Getting Help',
38
+ '',
39
+ '```bash',
40
+ '# Show this overview',
41
+ 'cnotes help',
42
+ '',
43
+ '# Show detailed help for a command',
44
+ 'cnotes help serve',
45
+ 'cnotes serve --help',
46
+ '```',
47
+ )
48
+
49
+ console.log(ui.markdown(lines.join('\n')))
50
+ }
51
+
52
+ commands.register('help', {
53
+ description: 'Show available commands',
54
+ help: `# cnotes help
55
+
56
+ Show available commands and usage information.
57
+
58
+ ## Usage
59
+
60
+ \`\`\`
61
+ cnotes help [command]
62
+ \`\`\`
63
+
64
+ ## Arguments
65
+
66
+ | Argument | Description |
67
+ |----------|-------------|
68
+ | \`command\` | Show detailed help for a specific command |
69
+
70
+ ## Examples
71
+
72
+ \`\`\`bash
73
+ # Show all commands
74
+ cnotes help
75
+
76
+ # Show help for the serve command
77
+ cnotes help serve
78
+ \`\`\`
79
+ `,
80
+ handler,
81
+ })
@@ -0,0 +1,17 @@
1
+ // Side-effect imports register each command
2
+ import './init.js'
3
+ import './create.js'
4
+ import './inspect.js'
5
+ import './validate.js'
6
+ import './export.js'
7
+ import './action.js'
8
+ import './summary.js'
9
+ import './teach.js'
10
+ import './extract.js'
11
+ import './mcp.js'
12
+ import './serve.js'
13
+ import './console.js'
14
+ import './help.js'
15
+ import './text-search.js'
16
+ import './search.js'
17
+ import './embed.js'