dsh-ensp 0.5.0 → 0.6.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
+ }
@@ -1,21 +1,18 @@
1
1
  /**
2
- * Regenerate `src/core/knowledge.json` from a certification question bank.
3
- *
4
- * The bank is not part of this package: it is copyrighted material, and most of
5
- * its questions are three-sentence reading exercises rather than facts. What the
6
- * package does ship is the index this script produces, so the transformation has
7
- * to be reproducible and reviewable by anyone who has the bank.
2
+ * Regenerate `src/core/knowledge.json`.
8
3
  *
9
4
  * Usage:
10
- * node scripts/gen-knowledge.mjs <bank.json>
5
+ * node scripts/gen-knowledge.mjs <source.json> [more.json ...]
11
6
  *
12
- * The bank is expected as an array of
13
- * `{ n, type, stem, options: [{key, text}], answer, analysis, topics }`.
7
+ * The source is any array of `{ n, type, stem, options: [{key, text}], answer,
8
+ * analysis, topics }`. It is not part of this package; what ships is the index
9
+ * this script produces, so the transformation has to be reproducible and
10
+ * reviewable by anyone who has the source.
14
11
  *
15
12
  * Two rules shape the output, and both exist because the obvious version of this
16
13
  * script produced a worse index:
17
14
  *
18
- * 1. **One topic per question.** The bank's own tags are unreliable: a question
15
+ * 1. **One topic per question.** Upstream tags are unreliable: a question
19
16
  * about OSPF LSA types carries `ospf` and `security` both, and honouring every
20
17
  * tag files the same fact under two headings. Score the text against topic
21
18
  * keywords and keep the best match.
@@ -47,8 +44,9 @@ const TOPIC_SCENARIOS = {
47
44
  mpls: ['mpls-basic', 'mpls-l3vpn'],
48
45
  ipv6: ['ipv6', 'ospfv3-ipv6'],
49
46
  qos: ['qos-basics'],
47
+ management: ['network-management'],
50
48
  reliability: ['vrrp-basics', 'bfd-fast-convergence', 'firewall-hrp', 'dual-homed-isp'],
51
- 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'],
52
50
  }
53
51
 
54
52
  /** Keywords that decide which topic a question belongs to. */
@@ -61,6 +59,7 @@ const TOPIC_TERMS = {
61
59
  mpls: ['mpls', 'ldp', 'lsr', '标签', 'vpn-instance'],
62
60
  ipv6: ['ipv6', 'ospfv3', 'slaac'],
63
61
  qos: ['qos', '队列', '拥塞', '流量监管', '优先级映射'],
62
+ management: ['网络管理', '网管', 'snmp', 'ntp', '日志', 'info-center', 'netconf', 'telemetry'],
64
63
  reliability: ['vrrp', 'bfd', 'hrp', '双机热备', '主备'],
65
64
  security: ['acl', '安全区域', 'security-policy', 'ipsec', 'ike', 'aaa', 'radius', '防火墙', 'nat'],
66
65
  }
@@ -68,8 +67,15 @@ const TOPIC_TERMS = {
68
67
  /** A question phrased this way keeps only a false statement once stripped. */
69
68
  const NEGATIVE = /错误的是|不正确的是|不属于|不支持|不能|无需/
70
69
 
71
- /** Points kept per topic. Enough to cover the vocabulary, short enough to read. */
72
- 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
73
79
 
74
80
  /** A question must score at least this many keyword hits to be filed at all. */
75
81
  const MIN_SCORE = 2
@@ -81,26 +87,41 @@ const clip = (s, n) => (s.length <= n ? s : s.slice(0, n - 1).replace(/[,,、
81
87
  * The answer as readable text.
82
88
  *
83
89
  * A bare key list (`A,C`) teaches nothing, so every chosen option is spelled out.
84
- * Returns '' when the answer is not machine-readable, which is the case for the
85
- * 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.
86
94
  */
87
95
  function answerText(q) {
88
96
  const keys = clean(q.answer).toUpperCase().match(/[A-Z]/g)
89
- if (!keys || !q.options?.length) return q.type === '判断题' ? clean(q.answer) : ''
90
- const picked = q.options.filter((o) => keys.includes(String(o.key).toUpperCase()))
91
- if (!picked.length) return ''
92
- 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 ''
93
107
  }
94
108
 
95
109
  /**
96
110
  * Build the index.
97
111
  *
98
- * @param {Array<object>} bank
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
99
120
  * @returns {object}
100
121
  */
101
- export function buildIndex(bank) {
122
+ export function buildIndex(source, carry = []) {
102
123
  const perTopic = {}
103
- for (const q of bank) {
124
+ for (const q of source) {
104
125
  const answer = answerText(q)
105
126
  if (!answer) continue
106
127
  const stem = clean(q.stem)
@@ -120,25 +141,36 @@ export function buildIndex(bank) {
120
141
  }
121
142
 
122
143
  const topics = {}
123
- 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] || []
124
146
  // Highest keyword score first, then the shortest stem: a one-line question
125
147
  // states a fact, while a long one describes a scene.
126
148
  const ranked = rows.slice().sort((a, b) => b.score - a.score || a.q.length - b.q.length)
127
149
  const seen = new Set()
128
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
+ }
129
162
  for (const r of ranked) {
163
+ if (points.length >= MAX_POINTS) break
130
164
  const key = r.q.slice(0, 40) + '|' + r.a.slice(0, 30)
131
165
  if (seen.has(key)) continue
132
166
  seen.add(key)
133
167
  points.push({ n: r.n, q: r.q, a: r.a })
134
- if (points.length >= MAX_POINTS) break
135
168
  }
136
169
  topics[topic] = { scenarios: TOPIC_SCENARIOS[topic], points }
137
170
  }
138
171
 
139
172
  return {
140
- generatedFrom: 'HCIP H12-821 单选/多选/判断题',
141
- sourceQuestions: bank.length,
173
+ version: 1,
142
174
  topics,
143
175
  }
144
176
  }
@@ -147,15 +179,18 @@ const invokedDirectly =
147
179
  process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
148
180
 
149
181
  if (invokedDirectly) {
150
- const bankPath = process.argv[2]
151
- if (!bankPath) {
152
- console.error('usage: node scripts/gen-knowledge.mjs <bank.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 ...]')
153
187
  process.exit(2)
154
188
  }
155
- const bank = JSON.parse(readFileSync(bankPath, 'utf8'))
156
- const index = buildIndex(bank)
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)
157
192
  const target = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'src', 'core', 'knowledge.json')
158
193
  writeFileSync(target, JSON.stringify(index, null, 1) + '\n', 'utf8')
159
194
  const total = Object.values(index.topics).reduce((sum, t) => sum + t.points.length, 0)
160
- console.log(`wrote src/core/knowledge.json (${total} points from ${bank.length} questions)`)
195
+ console.log(`wrote src/core/knowledge.json (${total} points from ${source.length} questions)`)
161
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)) {