dsh-ensp 0.5.1 → 0.7.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.
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Build the curriculum index from extracted vendor lab manuals.
3
+ *
4
+ * The manuals are hundreds of pages each and cannot ship: they are vendor
5
+ * documentation, they are far larger than this package, and most of a page is
6
+ * prose that a tool has no use for. What *is* useful is the skeleton of each
7
+ * chapter: what it claims to teach, which `display` commands it uses to prove the
8
+ * claim, and which built-in scenario covers the same ground. That skeleton is
9
+ * small, factual, and stable across manual revisions.
10
+ *
11
+ * Input is the page-by-page JSON produced from the PDFs by the extractor in
12
+ * `_huawei/pages.py`; output is `src/core/curriculum.json`. The extractor is not
13
+ * part of the package because it needs the PDFs, which are not redistributable.
14
+ *
15
+ * node scripts/gen-curriculum.mjs <guides.json> [more.json ...]
16
+ *
17
+ * @module scripts/gen-curriculum
18
+ */
19
+
20
+ import { readFileSync, writeFileSync } from 'node:fs'
21
+ import { fileURLToPath } from 'node:url'
22
+ import path from 'node:path'
23
+
24
+ /** Manuals whose chapters duplicate another manual; keep the first of each pair. */
25
+ const SKIP_MANUAL = /YunShan_OS|eNSP_Pro/
26
+
27
+ /**
28
+ * Which built-in scenarios a manual chapter teaches.
29
+ *
30
+ * This mapping is the actual editorial work. It is keyed by
31
+ * `<level>:<chapter title>` so it survives a chapter being renumbered in a later
32
+ * manual revision, and so a missing chapter is a visible gap rather than a silent
33
+ * mis-filing.
34
+ */
35
+ const CHAPTER_SCENARIOS = {
36
+ 'HCIA:华为网络设备基本操作': ['vlan-access', 'trunk-interconnect'],
37
+ 'HCIA:VLAN 配置实验': ['vlan-access', 'trunk-interconnect', 'vlanif-routing', 'super-vlan'],
38
+ 'HCIA:生成树配置实验': ['stp-rstp', 'mstp-load-balance'],
39
+ 'HCIA:以太网链路聚合实验': ['link-aggregation'],
40
+ 'HCIA:直连路由实验': ['vlanif-routing'],
41
+ 'HCIA:静态路由实验': ['static-route'],
42
+ 'HCIA:IP 地址自动配置实验': ['dhcp', 'dhcp-snooping'],
43
+ 'HCIA:OSPF 配置实验': ['ospf-single'],
44
+ 'HCIA:IPv6 地址配置实验': ['ipv6', 'ospfv3-ipv6'],
45
+ 'HCIA:ACL 配置实验': ['acl-filter', 'nat'],
46
+ 'HCIA:文件管理实验': ['aaa-local', 'network-management'],
47
+ 'HCIA:远程登录实验': ['aaa-local', 'network-management'],
48
+ 'HCIA:NAT 配置实验': ['nat', 'acl-filter'],
49
+ 'HCIA:WAC+FIT AP 组网实验': ['wlan-basic', 'wlan-roaming'],
50
+ 'HCIA:网络编程自动化实验': ['network-management'],
51
+ 'HCIA:网络故障排除实验': ['ospf-troubleshooting', 'bgp-troubleshooting'],
52
+ 'HCIA:园区网络综合实验': ['campus-network', 'vlanif-routing', 'static-route'],
53
+
54
+ 'HCIP:OSPF 基础实验': [
55
+ 'ospf-single',
56
+ 'ospf-multi-area',
57
+ 'ospf-lsa-types',
58
+ 'ospf-stub-nssa',
59
+ 'ospf-virtual-link',
60
+ ],
61
+ 'HCIP:IS-IS 基础实验': ['isis-multi-level'],
62
+ 'HCIP:BGP 实验': ['bgp-ebgp-ibgp', 'bgp-route-reflector', 'bgp-aggregation'],
63
+ 'HCIP:路由策略与路由控制实验': ['route-policy-pbr', 'bgp-attributes'],
64
+ 'HCIP:RSTP 与MSTP 实验': ['stp-rstp', 'mstp-load-balance', 'vrrp-basics'],
65
+ 'HCIP:组播实验': ['multicast-pim-sm'],
66
+ 'HCIP:防火墙技术实验': ['firewall-zones', 'nat'],
67
+ 'HCIP:VRRP 实验': ['vrrp-basics'],
68
+ 'HCIP:DHCP 实验': ['dhcp', 'dhcp-snooping'],
69
+ 'HCIP:WLAN 实验': ['wlan-basic', 'wlan-roaming'],
70
+
71
+ 'HCIP:IGP 高级特性实验': ['ospf-troubleshooting', 'bfd-fast-convergence', 'isis-multi-level'],
72
+ 'HCIP:BGP 高级特性实验': ['bgp-troubleshooting', 'bgp-attributes', 'bgp-aggregation'],
73
+ 'HCIP:IPv6 实验': ['ipv6', 'ospfv3-ipv6'],
74
+ 'HCIP:VLAN 高级技术实验': ['super-vlan', 'port-isolation'],
75
+ 'HCIP:以太网交换安全实验': ['port-security', 'arp-security', 'dhcp-snooping'],
76
+ 'HCIP:BGP/MPLS IP VPN 实验': ['mpls-basic', 'mpls-l3vpn'],
77
+ 'HCIP:网络准入控制实验': ['port-security', 'aaa-local', 'dhcp-snooping'],
78
+ 'HCIP:VXLAN 与EVPN 实验': ['spine-leaf-vxlan', 'mpls-basic'],
79
+
80
+ 'HCIE:IPv4 路由技术': ['ospf-troubleshooting', 'isis-multi-level', 'bgp-troubleshooting', 'bfd-fast-convergence'],
81
+ 'HCIE:MPLS VPN': ['mpls-basic', 'mpls-l3vpn', 'ipsec-gre-tunnel'],
82
+ 'HCIE:EVPN': ['mpls-basic', 'spine-leaf-vxlan'],
83
+ 'HCIE:IPv6 路由技术': ['ospfv3-ipv6', 'ipv6'],
84
+ 'HCIE:VXLAN 实验': ['spine-leaf-vxlan'],
85
+ 'HCIE:VXLAN 虚拟化园区部署': ['spine-leaf-vxlan', 'wlan-roaming'],
86
+ 'HCIE:广域互联网络部署': ['dual-homed-isp', 'ipsec-gre-tunnel'],
87
+ 'HCIE:SR-MPLS 实验': ['mpls-basic', 'mpls-l3vpn'],
88
+ 'HCIE:SRv6 实验': ['ipv6', 'ospfv3-ipv6', 'mpls-l3vpn'],
89
+ 'HCIE:网络开放可编程': ['network-management'],
90
+ }
91
+
92
+ /** Drop sentences that are exam questions or half-cut lines from the extraction. */
93
+ const NOISE = /^(分析|思考|讨论|如果|若|假设|为什么|如何)|?$|,$|、$/
94
+
95
+ const MAX_GOALS = 6
96
+ const MAX_VERIFY = 10
97
+
98
+ /** Chapter titles carry suffixes such as (可选)that vary between manual editions. */
99
+ function titleKey(title) {
100
+ return tidy(title).replace(/[((][^))]{1,8}[))]$/,'').trim()
101
+ }
102
+
103
+ function tidy(text) {
104
+ return String(text ?? '')
105
+ .replace(/\s+/g, ' ')
106
+ .replace(/(/g, '(')
107
+ .replace(/)/g, ')')
108
+ .trim()
109
+ }
110
+
111
+ /** `display ip routing-tableprotocol ospf` comes back fused; split it. */
112
+ function fixCommand(cmd) {
113
+ return tidy(cmd).replace(/(routing-table|interface|brief|peer)(protocol|interface|verbose)/gi, '$1 $2')
114
+ }
115
+
116
+ function pick(list, max, filter = () => true) {
117
+ const out = []
118
+ for (const raw of list) {
119
+ const value = tidy(raw)
120
+ if (!value || value.length < 4 || NOISE.test(value)) continue
121
+ if (!filter(value)) continue
122
+ if (out.includes(value)) continue
123
+ out.push(value)
124
+ if (out.length >= max) break
125
+ }
126
+ return out
127
+ }
128
+
129
+ export function buildCurriculum(manuals) {
130
+ const entries = []
131
+ const seen = new Set()
132
+ for (const chapter of manuals) {
133
+ if (SKIP_MANUAL.test(chapter.manual)) continue
134
+ const title = tidy(chapter.title)
135
+ const key = `${chapter.level}:${titleKey(title)}`
136
+ if (seen.has(key)) continue
137
+ seen.add(key)
138
+ entries.push({
139
+ level: chapter.level,
140
+ title,
141
+ page: chapter.page,
142
+ scenarios: CHAPTER_SCENARIOS[key] || [],
143
+ goals: pick(chapter.goals || [], MAX_GOALS, (g) => /^(实现|描述|阐明|掌握|理解|了解|配置)/.test(g)),
144
+ verify: pick((chapter.verify || []).map(fixCommand), MAX_VERIFY, (c) => /^(display|ping|tracert)\s/.test(c)),
145
+ })
146
+ }
147
+ entries.sort((a, b) => {
148
+ const order = { HCIA: 0, HCIP: 1, HCIE: 2 }
149
+ return (order[a.level] ?? 9) - (order[b.level] ?? 9) || a.page - b.page
150
+ })
151
+ const byLevel = { HCIA: [], HCIP: [], HCIE: [] }
152
+ for (const entry of entries) (byLevel[entry.level] ||= []).push(entry)
153
+ return { version: 1, levels: byLevel }
154
+ }
155
+
156
+ const invokedDirectly =
157
+ process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
158
+
159
+ if (invokedDirectly) {
160
+ const sources = process.argv.slice(2).filter((a) => !a.startsWith('--'))
161
+ if (!sources.length) {
162
+ console.error('usage: node scripts/gen-curriculum.mjs <guides.json> [more.json ...]')
163
+ process.exit(2)
164
+ }
165
+ const chapters = []
166
+ for (const file of sources) {
167
+ const parsed = JSON.parse(readFileSync(file, 'utf8'))
168
+ chapters.push(...(Array.isArray(parsed) ? parsed : parsed.chapters || []))
169
+ }
170
+ const index = buildCurriculum(chapters)
171
+ const target = new URL('../src/core/curriculum.json', import.meta.url)
172
+ writeFileSync(target, JSON.stringify(index, null, 1) + '\n')
173
+ const counts = Object.entries(index.levels).map(([k, v]) => `${k} ${v.length}`)
174
+ const mapped = Object.values(index.levels)
175
+ .flat()
176
+ .filter((e) => e.scenarios.length).length
177
+ const bytes = JSON.stringify(index).length
178
+ console.log(
179
+ `wrote src/core/curriculum.json (${counts.join(', ')}; ${mapped} chapters mapped to scenarios; ${bytes} bytes)`,
180
+ )
181
+ }
@@ -2,7 +2,7 @@
2
2
  * Regenerate `src/core/knowledge.json`.
3
3
  *
4
4
  * Usage:
5
- * node scripts/gen-knowledge.mjs <source.json>
5
+ * node scripts/gen-knowledge.mjs <source.json> [more.json ...]
6
6
  *
7
7
  * The source is any array of `{ n, type, stem, options: [{key, text}], answer,
8
8
  * analysis, topics }`. It is not part of this package; what ships is the index
@@ -44,8 +44,9 @@ const TOPIC_SCENARIOS = {
44
44
  mpls: ['mpls-basic', 'mpls-l3vpn'],
45
45
  ipv6: ['ipv6', 'ospfv3-ipv6'],
46
46
  qos: ['qos-basics'],
47
+ management: ['network-management'],
47
48
  reliability: ['vrrp-basics', 'bfd-fast-convergence', 'firewall-hrp', 'dual-homed-isp'],
48
- security: ['acl-filter', 'aaa-local', 'ipsec-vpn', 'firewall-zones', 'firewall-hrp', 'arp-security', 'dhcp-snooping'],
49
+ security: ['acl-filter', 'aaa-local', 'ipsec-vpn', 'firewall-zones', 'firewall-hrp', 'arp-security', 'dhcp-snooping', 'port-security'],
49
50
  }
50
51
 
51
52
  /** Keywords that decide which topic a question belongs to. */
@@ -58,6 +59,7 @@ const TOPIC_TERMS = {
58
59
  mpls: ['mpls', 'ldp', 'lsr', '标签', 'vpn-instance'],
59
60
  ipv6: ['ipv6', 'ospfv3', 'slaac'],
60
61
  qos: ['qos', '队列', '拥塞', '流量监管', '优先级映射'],
62
+ management: ['网络管理', '网管', 'snmp', 'ntp', '日志', 'info-center', 'netconf', 'telemetry'],
61
63
  reliability: ['vrrp', 'bfd', 'hrp', '双机热备', '主备'],
62
64
  security: ['acl', '安全区域', 'security-policy', 'ipsec', 'ike', 'aaa', 'radius', '防火墙', 'nat'],
63
65
  }
@@ -65,8 +67,15 @@ const TOPIC_TERMS = {
65
67
  /** A question phrased this way keeps only a false statement once stripped. */
66
68
  const NEGATIVE = /错误的是|不正确的是|不属于|不支持|不能|无需/
67
69
 
68
- /** Points kept per topic. Enough to cover the vocabulary, short enough to read. */
69
- const MAX_POINTS = 8
70
+ /**
71
+ * Points kept per topic.
72
+ *
73
+ * The brief renders at most ten of them, so this cap is about how much depth a
74
+ * topic carries into the index, not about screen space. It has to be generous:
75
+ * every source that feeds the index contributes its own best rows, and a tight
76
+ * cap lets a later source push out an earlier one entirely.
77
+ */
78
+ const MAX_POINTS = 24
70
79
 
71
80
  /** A question must score at least this many keyword hits to be filed at all. */
72
81
  const MIN_SCORE = 2
@@ -78,24 +87,39 @@ const clip = (s, n) => (s.length <= n ? s : s.slice(0, n - 1).replace(/[,,、
78
87
  * The answer as readable text.
79
88
  *
80
89
  * A bare key list (`A,C`) teaches nothing, so every chosen option is spelled out.
81
- * Returns '' when the answer is not machine-readable, which is the case for the
82
- * drag-and-drop and fill-in-the-blank questions.
90
+ * When the option text is unusable the explanation carries the fact instead: it
91
+ * is the part a reader can actually learn from, and dropping the question because
92
+ * its options came back blank throws away the answer along with them. Returns ''
93
+ * only when neither source says anything.
83
94
  */
84
95
  function answerText(q) {
85
96
  const keys = clean(q.answer).toUpperCase().match(/[A-Z]/g)
86
- if (!keys || !q.options?.length) return q.type === '判断题' ? clean(q.answer) : ''
87
- const picked = q.options.filter((o) => keys.includes(String(o.key).toUpperCase()))
88
- if (!picked.length) return ''
89
- return picked.map((o) => `${o.key}. ${clip(clean(o.text), 70)}`).join(' / ')
97
+ const picked = (q.options || []).filter(
98
+ (o) => keys?.includes(String(o.key).toUpperCase()) && clean(o.text).replace(/^[.、\s]*$/, ''),
99
+ )
100
+ if (picked.length) {
101
+ return picked.map((o) => `${o.key}. ${clip(clean(o.text), 70)}`).join(' / ')
102
+ }
103
+ const explained = clean(q.analysis)
104
+ if (explained.length >= 12) return clip(explained, 130)
105
+ if (q.type === '判断题') return clean(q.answer) || ''
106
+ return ''
90
107
  }
91
108
 
92
109
  /**
93
110
  * Build the index.
94
111
  *
95
- * @param {Array<object>} source
112
+ * Two sources feed one topic's points: the keyword pass over the raw questions,
113
+ * and `carry` rows that are already in the shipped shape (`{topic, n, q, a}`).
114
+ * The carry path exists because the index is built from more than one source
115
+ * over time, and rebuilding from a single source would silently drop whatever
116
+ * the other one contributed.
117
+ *
118
+ * @param {Array<object>} source raw questions
119
+ * @param {Array<{topic: string, n: number, q: string, a: string}>} [carry] points to keep as-is
96
120
  * @returns {object}
97
121
  */
98
- export function buildIndex(source) {
122
+ export function buildIndex(source, carry = []) {
99
123
  const perTopic = {}
100
124
  for (const q of source) {
101
125
  const answer = answerText(q)
@@ -117,18 +141,30 @@ export function buildIndex(source) {
117
141
  }
118
142
 
119
143
  const topics = {}
120
- for (const [topic, rows] of Object.entries(perTopic)) {
144
+ for (const topic of new Set([...Object.keys(perTopic), ...carry.map((c) => c.topic)])) {
145
+ const rows = perTopic[topic] || []
121
146
  // Highest keyword score first, then the shortest stem: a one-line question
122
147
  // states a fact, while a long one describes a scene.
123
148
  const ranked = rows.slice().sort((a, b) => b.score - a.score || a.q.length - b.q.length)
124
149
  const seen = new Set()
125
150
  const points = []
151
+ // Carried points go first. They were already reviewed when they shipped, so a
152
+ // later source adds to them rather than displacing them; the alternative
153
+ // silently traded an established fact for a new one that scored higher.
154
+ for (const c of carry) {
155
+ if (c.topic !== topic) continue
156
+ if (points.length >= MAX_POINTS) break
157
+ const key = c.q.slice(0, 40) + '|' + c.a.slice(0, 30)
158
+ if (seen.has(key)) continue
159
+ seen.add(key)
160
+ points.push({ n: c.n, q: c.q, a: c.a })
161
+ }
126
162
  for (const r of ranked) {
163
+ if (points.length >= MAX_POINTS) break
127
164
  const key = r.q.slice(0, 40) + '|' + r.a.slice(0, 30)
128
165
  if (seen.has(key)) continue
129
166
  seen.add(key)
130
167
  points.push({ n: r.n, q: r.q, a: r.a })
131
- if (points.length >= MAX_POINTS) break
132
168
  }
133
169
  topics[topic] = { scenarios: TOPIC_SCENARIOS[topic], points }
134
170
  }
@@ -143,15 +179,18 @@ const invokedDirectly =
143
179
  process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
144
180
 
145
181
  if (invokedDirectly) {
146
- const sourcePath = process.argv[2]
147
- if (!sourcePath) {
148
- console.error('usage: node scripts/gen-knowledge.mjs <source.json>')
182
+ const args = process.argv.slice(2)
183
+ const carryPath = args.find((a) => a.startsWith('--carry='))?.slice('--carry='.length)
184
+ const sourcePaths = args.filter((a) => !a.startsWith('--'))
185
+ if (!sourcePaths.length) {
186
+ console.error('usage: node scripts/gen-knowledge.mjs [--carry=index.json] <source.json> [more.json ...]')
149
187
  process.exit(2)
150
188
  }
151
- const source = JSON.parse(readFileSync(sourcePath, 'utf8'))
152
- const index = buildIndex(source)
189
+ const source = sourcePaths.flatMap((p) => JSON.parse(readFileSync(p, 'utf8')))
190
+ const carry = carryPath ? JSON.parse(readFileSync(carryPath, 'utf8')) : []
191
+ const index = buildIndex(source, carry)
153
192
  const target = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'core', 'knowledge.json')
154
193
  writeFileSync(target, JSON.stringify(index, null, 1) + '\n', 'utf8')
155
194
  const total = Object.values(index.topics).reduce((sum, t) => sum + t.points.length, 0)
156
- console.log(`wrote src/core/knowledge.json (${total} points)`)
195
+ console.log(`wrote src/core/knowledge.json (${total} points from ${source.length} questions)`)
157
196
  }
@@ -19,6 +19,7 @@ import { SCENARIOS, SCENARIO_IDS, byLevel } from '../src/core/presets.mjs'
19
19
  import { LABS, LAB_IDS, hasLab, labAddressing, labDevices, labModels } from '../src/core/labs.mjs'
20
20
  import { imageNotesFor } from '../src/core/compat.mjs'
21
21
  import { hasChecks, EXPECTATIONS } from '../src/core/acceptance.mjs'
22
+ import { curriculumFor } from '../src/core/curriculum.mjs'
22
23
 
23
24
  const here = path.dirname(fileURLToPath(import.meta.url))
24
25
  const root = path.resolve(here, '..')
@@ -137,6 +138,16 @@ function render(catalogue = SCENARIOS) {
137
138
  : `- 验收:\`ensp verify ${id} --topology ${id}.topo\`(无专门断言,逐条跑本场景的验证命令)`
138
139
  out.push(verifyLine)
139
140
  }
141
+
142
+ // Which vendor manual chapter teaches this scenario, when one does.
143
+ const chapters = curriculumFor(id)
144
+ if (chapters.length) {
145
+ out.push('', '**教材章节**')
146
+ for (const chapter of chapters) {
147
+ const where = `${chapter.level} · ${chapter.title}(手册第 ${chapter.page} 页)`
148
+ out.push(`- ${where}`)
149
+ }
150
+ }
140
151
  out.push('')
141
152
  }
142
153
  }
package/src/cli/ensp.mjs CHANGED
@@ -36,6 +36,7 @@ import { runAcceptance, formatAcceptance, hasChecks, planFor } from '../core/acc
36
36
  import { pushConfig } from '../core/provision.mjs'
37
37
  import { SCENARIOS, SCENARIO_IDS, byLevel, renderScenario, searchScenarios } from '../core/presets.mjs'
38
38
  import { knowledgeFor, renderKnowledge, searchKnowledge } from '../core/knowledge.mjs'
39
+ import { curriculumFor, renderCurriculum, searchCurriculum } from '../core/curriculum.mjs'
39
40
  import {
40
41
  LAB_IDS,
41
42
  buildScenarioTopology,
@@ -65,7 +66,7 @@ ${C.bold('USAGE')}
65
66
  ensp verify <scenario-id> --topology <topology.topo> [--json] [--wait MS]
66
67
  ensp probe [--json]
67
68
  ensp stop [--topology <topology.topo>]
68
- ensp preset [<id>] [--search <keyword>] [--topo <out.topo>] [--json]
69
+ ensp preset [<id>] [--search <keyword>] [--syllabus <keyword>] [--topo <out.topo>] [--json]
69
70
  ensp mcp (start the MCP server on stdio)
70
71
 
71
72
  ${C.bold('OPTIONS')}
@@ -75,6 +76,8 @@ ${C.bold('OPTIONS')}
75
76
  --device NAME apply only to one device
76
77
  --timeout MS per-device readiness timeout (default 240000)
77
78
  --wait MS verify: how long a failing assertion may keep being retried (default 60000)
79
+ --syllabus KW search the vendor manual chapter index (goals and verification commands)
80
+ --knowledge KW search the term index (short answers filed by topic)
78
81
  --topo PATH write the scenario's topology blueprint to a .topo file
79
82
  --topology PATH the .topo file that is running (doctor, verify)
80
83
  --exe PATH full path to eNSP_Client.exe (default: auto-detect via registry and install roots)
@@ -92,6 +95,7 @@ ${C.bold('EXAMPLES')}
92
95
  ensp up lab.topo --exe "C:/Program Files/Huawei/eNSP/eNSP_Client.exe"
93
96
  ensp probe
94
97
  ensp preset --search ospf
98
+ ensp preset --syllabus "MPLS"
95
99
  ensp preset ospf-single
96
100
  ensp preset campus-network --topo campus.topo
97
101
  ensp mcp
@@ -321,6 +325,24 @@ async function cmdStatus({ flags }) {
321
325
  async function cmdPreset({ positional, flags }) {
322
326
  const id = positional[0]
323
327
 
328
+ if (flags.syllabus) {
329
+ const hits = searchCurriculum(String(flags.syllabus))
330
+ if (flags.json) {
331
+ console.log(JSON.stringify(hits, null, 2))
332
+ return hits.length ? 0 : 1
333
+ }
334
+ if (!hits.length) {
335
+ console.log(C.yellow('no manual chapter matched') + ` "${flags.syllabus}"`)
336
+ return 1
337
+ }
338
+ for (const h of hits) {
339
+ console.log(`${C.bold(h.level)} ${h.title} ${C.dim('p' + h.page)}`)
340
+ if (h.scenarios?.length) console.log(C.dim(` 场景: ${h.scenarios.join(', ')}`))
341
+ if (h.verify?.length) console.log(C.dim(` 验证: ${h.verify.slice(0, 3).join(' / ')}`))
342
+ }
343
+ return 0
344
+ }
345
+
324
346
  if (flags.knowledge) {
325
347
  const hits = searchKnowledge(String(flags.knowledge))
326
348
  if (flags.json) {
@@ -411,7 +433,19 @@ async function cmdPreset({ positional, flags }) {
411
433
 
412
434
  const text = renderScenario(id)
413
435
  const points = knowledgeFor(id)
414
- console.log(flags.json ? JSON.stringify({ ...SCENARIOS[id], knowledge: points }, null, 2) : text)
436
+ const chapters = curriculumFor(id).map((c) => ({
437
+ level: c.level,
438
+ title: c.title,
439
+ page: c.page,
440
+ verify: c.verify,
441
+ }))
442
+ console.log(
443
+ flags.json
444
+ ? JSON.stringify({ ...SCENARIOS[id], knowledge: points, curriculum: chapters }, null, 2)
445
+ : text,
446
+ )
447
+ const taught = renderCurriculum(id)
448
+ if (taught) console.log(taught)
415
449
  const knowledge = renderKnowledge(id)
416
450
  if (knowledge) console.log(knowledge)
417
451
  if (hasLab(id)) {