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.
- package/.mcp.json +9 -0
- package/CLI.md +593 -0
- package/MCP-DESCRIPTIONS-REVIEW.md +122 -0
- package/MCP-SERVER-SPEC.md +453 -0
- package/PRIMER.md +540 -0
- package/README.md +406 -13
- package/bun.lock +45 -6
- package/dist/cnotes +0 -0
- package/docs/README.md +110 -0
- package/docs/TABLE-OF-CONTENTS.md +7 -0
- package/docs/models.ts +38 -0
- package/models.ts +38 -0
- package/package.json +14 -4
- package/public/web-demo/index.html +813 -0
- package/scripts/examples/01-collection-setup.ts +46 -0
- package/scripts/examples/02-querying.ts +67 -0
- package/scripts/examples/03-sections.ts +36 -0
- package/scripts/examples/04-relationships.ts +54 -0
- package/scripts/examples/05-document-api.ts +54 -0
- package/scripts/examples/06-extract-sections.ts +55 -0
- package/scripts/examples/07-validation.ts +46 -0
- package/scripts/examples/08-serialization.ts +51 -0
- package/scripts/examples/lib/format.ts +87 -0
- package/scripts/examples/lib/setup.ts +21 -0
- package/scripts/examples/run-all.ts +43 -0
- package/showcases/node_modules/.cache/.repl_history +3 -0
- package/showcases/vinyl-collection/artists/nina-simone.mdx +1 -1
- package/src/__tests__/semantic-search.integration.test.ts +284 -0
- package/src/api/endpoints/actions.ts +34 -0
- package/src/api/endpoints/doc.ts +187 -0
- package/src/api/endpoints/docs-index.ts +26 -0
- package/src/api/endpoints/document.ts +171 -0
- package/src/api/endpoints/documents.ts +71 -0
- package/src/api/endpoints/inspect.ts +16 -0
- package/src/api/endpoints/models.ts +10 -0
- package/src/api/endpoints/query.ts +88 -0
- package/src/api/endpoints/root.ts +7 -0
- package/src/api/endpoints/search-reindex.ts +65 -0
- package/src/api/endpoints/search-status.ts +55 -0
- package/src/api/endpoints/search.ts +104 -0
- package/src/api/endpoints/semantic-search.ts +120 -0
- package/src/api/endpoints/text-search.ts +63 -0
- package/src/api/endpoints/validate.ts +34 -0
- package/src/api/helpers.ts +97 -0
- package/src/base-model.ts +12 -0
- package/src/cli/commands/action.ts +82 -44
- package/src/cli/commands/console.ts +124 -0
- package/src/cli/commands/create.ts +179 -53
- package/src/cli/commands/embed.ts +323 -0
- package/src/cli/commands/export.ts +58 -24
- package/src/cli/commands/extract.ts +174 -0
- package/src/cli/commands/help.ts +81 -0
- package/src/cli/commands/index.ts +17 -0
- package/src/cli/commands/init.ts +72 -48
- package/src/cli/commands/inspect.ts +56 -46
- package/src/cli/commands/mcp.ts +1219 -0
- package/src/cli/commands/search.ts +285 -0
- package/src/cli/commands/serve.ts +348 -0
- package/src/cli/commands/summary.ts +60 -0
- package/src/cli/commands/teach.ts +86 -0
- package/src/cli/commands/text-search.ts +134 -0
- package/src/cli/commands/validate.ts +126 -64
- package/src/cli/index.ts +88 -19
- package/src/cli/load-collection.ts +144 -17
- package/src/cli/registry.ts +28 -0
- package/src/collection.ts +455 -6
- package/src/define-model.ts +104 -7
- package/src/document.ts +47 -1
- package/src/extract-sections.ts +222 -0
- package/src/index.ts +20 -2
- package/src/model-instance.ts +35 -5
- package/src/node-shortcuts.ts +1 -1
- package/src/query/collection-query.ts +96 -9
- package/src/query/index.ts +7 -0
- package/src/query/query-dsl.ts +259 -0
- package/src/relationships/has-many.ts +39 -0
- package/src/relationships/index.ts +7 -2
- package/src/section.ts +2 -0
- package/src/types.ts +24 -2
- package/src/utils/index.ts +1 -0
- package/src/utils/match-pattern.ts +65 -0
- package/src/validator.ts +18 -1
- package/test/collection.test.ts +118 -2
- package/test/extract-sections.test.ts +356 -0
- package/test/fixtures/sdlc/MODELS.md +106 -0
- package/test/fixtures/sdlc/SKILL.md +404 -0
- package/test/fixtures/sdlc/index.ts +9 -0
- package/test/fixtures/sdlc/models.ts +8 -6
- package/test/fixtures/sdlc/stories/authentication/a-user-should-be-able-to-login.mdx +20 -0
- package/test/fixtures/sdlc/templates/epic.md +23 -0
- package/test/fixtures/sdlc/templates/story.md +19 -0
- package/test/pattern.test.ts +191 -0
- package/test/query-dsl.test.ts +431 -0
- package/test/query.test.ts +29 -0
- package/test/relationships.test.ts +130 -0
- package/test/section.test.ts +65 -4
- 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 {
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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'
|