dsh-ensp 0.1.2 → 0.1.6

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/src/dsh/index.mjs CHANGED
@@ -1,387 +1,595 @@
1
- /**
2
- * DeepSeek Harness plugin row.
3
- *
4
- * The plugin is a thin adapter: it registers the same operations the CLI and the
5
- * MCP server expose as **model tools**, so an agent can drive an eNSP lab directly
6
- * inside a Harness session. No behaviour lives here — everything delegates to
7
- * `src/core`, which keeps the three front-ends from drifting apart.
8
- *
9
- * It publishes no service of its own; it only consumes `tools`, so no `isolate`
10
- * realm is required.
11
- *
12
- * @module dsh-ensp/dsh
13
- */
14
-
15
- import { readFile, readdir } from 'node:fs/promises'
16
- import path from 'node:path'
17
-
18
- import { buildTopology, twoPcLab, twoPcLabConfig, autoLayout, MODEL_SPECS } from '../core/build.mjs'
19
- import { readTopology, writeTopology, summarise } from '../core/topology.mjs'
20
- import {
21
- enspPid,
22
- locateWindow,
23
- launchEnsp,
24
- startAllDevices,
25
- stopAllDevices,
26
- waitForConsole,
27
- } from '../core/launcher.mjs'
28
- import { calibrateAnchors, inspectAnchors } from '../core/calibrate.mjs'
29
- import { pushConfig, pushConfigMany, queryDevice } from '../core/provision.mjs'
30
- import { SCENARIOS, renderScenario, searchScenarios, byLevel } from '../core/presets.mjs'
31
-
32
- /** Declared hard dependency: the tool registry. */
33
- export const inject = ['tools']
34
-
35
- export const name = 'ensp'
36
-
37
- /**
38
- * Register the tools.
39
- *
40
- * Each tool returns compact JSON rather than prose so the model can branch on the
41
- * result. Failures are returned as `{ isError: true }` content instead of thrown,
42
- * because a thrown tool error gives the model nothing to act on.
43
- *
44
- * @param {object} ctx - Cordis context carrying `tools`
45
- */
46
- export function apply(ctx) {
47
- const tools = ctx.tools
48
- if (!tools?.register) {
49
- console.warn('[dsh-ensp] tools service unavailable; plugin inactive')
50
- return
51
- }
52
-
53
- const register = (definition, handler) => {
54
- tools.register({
55
- ...definition,
56
- async execute(args) {
57
- try {
58
- const value = await handler(args ?? {})
59
- return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] }
60
- } catch (error) {
61
- return {
62
- content: [{ type: 'text', text: `error: ${error.message}` }],
63
- isError: true,
64
- }
65
- }
66
- },
67
- })
68
- }
69
-
70
- register(
71
- {
72
- name: 'ensp_status',
73
- description:
74
- 'Report eNSP state: running or not, window handle, calibrated toolbar anchors, and ' +
75
- 'per-device console readiness when a topology path is supplied.',
76
- parameters: {
77
- type: 'object',
78
- properties: { topology: { type: 'string' } },
79
- additionalProperties: false,
80
- },
81
- },
82
- async ({ topology }) => {
83
- const pid = enspPid()
84
- if (!pid) return { running: false }
85
- const hwnd = locateWindow(pid)
86
- const out = { running: true, pid, hwnd }
87
- if (hwnd) {
88
- const a = calibrateAnchors(hwnd)
89
- out.anchors = { start: a.start, stop: a.stop, method: a.method }
90
- }
91
- if (topology) {
92
- const topo = await readTopology(topology)
93
- out.devices = await Promise.all(
94
- topo.devices
95
- .filter((d) => d.comPort)
96
- .map(async (d) => {
97
- const r = await waitForConsole({ port: d.comPort, timeoutMs: 4000 })
98
- return { name: d.name, port: d.comPort, ready: r.ready, prompt: r.prompt }
99
- }),
100
- )
101
- }
102
- return out
103
- },
104
- )
105
-
106
- register(
107
- {
108
- name: 'ensp_build_topology',
109
- description:
110
- 'Generate a .topo file. preset "two-pc" builds the two-PC/one-switch lab; otherwise ' +
111
- 'pass devices and links (links use VRP interface names).',
112
- parameters: {
113
- type: 'object',
114
- properties: {
115
- path: { type: 'string' },
116
- preset: { type: 'string', enum: ['two-pc'] },
117
- vlan: { type: 'number' },
118
- devices: { type: 'array', items: { type: 'object' } },
119
- links: { type: 'array', items: { type: 'object' } },
120
- },
121
- required: ['path'],
122
- additionalProperties: false,
123
- },
124
- },
125
- async (args) => {
126
- let topo
127
- if (args.preset === 'two-pc' || (!args.devices && !args.links)) {
128
- topo = twoPcLab({ vlan: args.vlan ?? 10 })
129
- } else {
130
- const b = buildTopology()
131
- for (const d of args.devices ?? []) b.add(d.name, d.model, { x: d.x, y: d.y, pc: d.pc })
132
- for (const l of args.links ?? []) b.link(l.a, l.aPort, l.b, l.bPort)
133
- topo = autoLayout(b.finish())
134
- }
135
- await writeTopology(path.resolve(args.path), topo)
136
- return { path: path.resolve(args.path), ...summarise(topo) }
137
- },
138
- )
139
-
140
- register(
141
- {
142
- name: 'ensp_open',
143
- description: 'Launch eNSP (or reuse it) and open a topology file.',
144
- parameters: {
145
- type: 'object',
146
- properties: { topology: { type: 'string' } },
147
- required: ['topology'],
148
- additionalProperties: false,
149
- },
150
- },
151
- async ({ topology }) => {
152
- const r = await launchEnsp({ topology: path.resolve(topology) })
153
- return { pid: r.pid, hwnd: r.hwnd, reused: Boolean(r.reused) }
154
- },
155
- )
156
-
157
- register(
158
- {
159
- name: 'ensp_start',
160
- description:
161
- 'Boot every device. Calibrates the toolbar by colour, clicks start, then polls each ' +
162
- 'console until VRP answers (readiness measured per device, never a fixed sleep). ' +
163
- 'Minimises the window afterwards unless keepWindow is true.',
164
- parameters: {
165
- type: 'object',
166
- properties: {
167
- topology: { type: 'string' },
168
- perDeviceTimeoutMs: { type: 'number' },
169
- keepWindow: { type: 'boolean' },
170
- },
171
- required: ['topology'],
172
- additionalProperties: false,
173
- },
174
- },
175
- async (args) => {
176
- const topo = await readTopology(path.resolve(args.topology))
177
- const hwnd = locateWindow(enspPid())
178
- if (!hwnd) throw new Error('eNSP window not found; call ensp_open first')
179
- const r = await startAllDevices({
180
- devices: topo.devices,
181
- hwnd,
182
- perDeviceTimeoutMs: args.perDeviceTimeoutMs ?? 240000,
183
- minimise: !args.keepWindow,
184
- })
185
- return {
186
- allReady: r.allReady,
187
- ready: r.ready,
188
- total: r.total,
189
- anchors: r.anchors,
190
- devices: r.devices.map((d) => ({ name: d.name, port: d.port, ready: d.ready, prompt: d.prompt })),
191
- }
192
- },
193
- )
194
-
195
- register(
196
- {
197
- name: 'ensp_stop',
198
- description: 'Shut down all devices in the running topology.',
199
- parameters: { type: 'object', properties: {}, additionalProperties: false },
200
- },
201
- async () => {
202
- const hwnd = locateWindow(enspPid())
203
- if (!hwnd) throw new Error('eNSP window not found')
204
- const r = stopAllDevices(hwnd)
205
- return { clicked: r.ok, anchors: r.anchors }
206
- },
207
- )
208
-
209
- register(
210
- {
211
- name: 'ensp_apply',
212
- description:
213
- 'Push a configuration script to one device, one command at a time. The first error ' +
214
- 'aborts the batch and reports the line, the command and the raw device output.',
215
- parameters: {
216
- type: 'object',
217
- properties: {
218
- device: { type: 'string' },
219
- port: { type: 'number' },
220
- script: { type: 'string' },
221
- save: { type: 'boolean' },
222
- },
223
- required: ['device', 'port', 'script'],
224
- additionalProperties: false,
225
- },
226
- },
227
- async (args) => {
228
- const r = await pushConfig({
229
- device: args.device,
230
- port: args.port,
231
- script: args.script,
232
- save: Boolean(args.save),
233
- })
234
- return {
235
- ok: r.ok,
236
- applied: r.applied,
237
- total: r.total,
238
- saved: r.saved,
239
- failedAt: r.failedAt,
240
- durationMs: r.durationMs,
241
- }
242
- },
243
- )
244
-
245
- register(
246
- {
247
- name: 'ensp_apply_dir',
248
- description: 'Push one .cfg per device from a directory, matched by file name to device name.',
249
- parameters: {
250
- type: 'object',
251
- properties: {
252
- directory: { type: 'string' },
253
- topology: { type: 'string' },
254
- save: { type: 'boolean' },
255
- concurrency: { type: 'number' },
256
- },
257
- required: ['directory', 'topology'],
258
- additionalProperties: false,
259
- },
260
- },
261
- async (args) => {
262
- const topo = await readTopology(path.resolve(args.topology))
263
- const consoles = new Map(topo.devices.filter((d) => d.comPort).map((d) => [d.name, d.comPort]))
264
- const files = (await readdir(args.directory)).filter((f) => /\.(cfg|txt)$/i.test(f))
265
- const targets = []
266
- for (const file of files) {
267
- const device = file.replace(/\.(cfg|txt)$/i, '')
268
- const port = consoles.get(device)
269
- if (!port) continue
270
- targets.push({ device, port, script: await readFile(path.join(args.directory, file), 'utf8') })
271
- }
272
- const results = await pushConfigMany({
273
- targets,
274
- concurrency: args.concurrency ?? 3,
275
- save: Boolean(args.save),
276
- })
277
- return {
278
- configured: results.filter((r) => r.ok).length,
279
- total: results.length,
280
- results: results.map((r) => ({ device: r.device, ok: r.ok, applied: r.applied, failedAt: r.failedAt })),
281
- }
282
- },
283
- )
284
-
285
- register(
286
- {
287
- name: 'ensp_query',
288
- description: 'Run one read-only command on a device console and return its output.',
289
- parameters: {
290
- type: 'object',
291
- properties: { port: { type: 'number' }, command: { type: 'string' } },
292
- required: ['port', 'command'],
293
- additionalProperties: false,
294
- },
295
- },
296
- async ({ port, command }) => {
297
- const r = await queryDevice({ port, command })
298
- return { ok: r.ok, output: r.output, error: r.error }
299
- },
300
- )
301
-
302
- register(
303
- {
304
- name: 'ensp_scenarios',
305
- description:
306
- 'List or search the 23 built-in scenarios. Each is a skeleton (what to create, in what ' +
307
- 'order, how to verify) rather than a command dump.',
308
- parameters: {
309
- type: 'object',
310
- properties: {
311
- search: { type: 'string' },
312
- level: { type: 'string', enum: ['basic', 'intermediate', 'advanced'] },
313
- },
314
- additionalProperties: false,
315
- },
316
- },
317
- async ({ search, level }) => {
318
- const groups = byLevel()
319
- let ids = level ? groups[level] ?? [] : Object.keys(SCENARIOS)
320
- if (search) {
321
- const matched = new Set(searchScenarios(search).map((h) => h.id))
322
- ids = ids.filter((id) => matched.has(id))
323
- }
324
- return ids.map((id) => ({
325
- id,
326
- title: SCENARIOS[id].title,
327
- level: SCENARIOS[id].level,
328
- goal: SCENARIOS[id].goal,
329
- }))
330
- },
331
- )
332
-
333
- register(
334
- {
335
- name: 'ensp_scenario',
336
- description: 'Return one scenario as a step-by-step brief with pitfalls.',
337
- parameters: {
338
- type: 'object',
339
- properties: { id: { type: 'string' } },
340
- required: ['id'],
341
- additionalProperties: false,
342
- },
343
- },
344
- async ({ id }) => {
345
- if (!SCENARIOS[id]) throw new Error(`unknown scenario "${id}"`)
346
- return { id, brief: renderScenario(id) }
347
- },
348
- )
349
-
350
- register(
351
- {
352
- name: 'ensp_preset_config',
353
- description: 'Return the configuration text for the built-in two-PC lab.',
354
- parameters: {
355
- type: 'object',
356
- properties: { vlan: { type: 'number' } },
357
- additionalProperties: false,
358
- },
359
- },
360
- async ({ vlan }) => ({ script: twoPcLabConfig({ vlan: vlan ?? 10 }) }),
361
- )
362
-
363
- register(
364
- {
365
- name: 'ensp_probe',
366
- description:
367
- 'Re-measure the toolbar anchors and report every colour cluster. Run after an eNSP ' +
368
- 'upgrade or a display-scaling change.',
369
- parameters: { type: 'object', properties: {}, additionalProperties: false },
370
- },
371
- async () => {
372
- const hwnd = locateWindow(enspPid())
373
- if (!hwnd) throw new Error('eNSP window not found')
374
- return inspectAnchors(hwnd)
375
- },
376
- )
377
- }
378
-
379
- /** Model metadata for the plugin row. */
380
- export const dsh = {
381
- name: 'dsh-ensp',
382
- version: '0.1.0',
383
- description: 'Automate Huawei eNSP labs: build topology, boot devices, push config with verification.',
384
- tools: ['ensp_status', 'ensp_build_topology', 'ensp_open', 'ensp_start', 'ensp_stop', 'ensp_apply', 'ensp_apply_dir', 'ensp_query', 'ensp_scenarios', 'ensp_scenario', 'ensp_preset_config', 'ensp_probe'],
385
- }
386
-
387
- export { MODEL_SPECS }
1
+ /**
2
+ * DeepSeek Harness plugin row.
3
+ *
4
+ * A thin adapter: it publishes the same operations the CLI and the MCP server
5
+ * expose as **model tools**, so an agent can drive an eNSP lab inside a Harness
6
+ * session. No behaviour lives here; everything delegates to `src/core`.
7
+ *
8
+ * ## Tool contract
9
+ *
10
+ * `ctx.tools.register` wants a shape that differs from the MCP schema in two ways,
11
+ * and getting either wrong fails activation with a schema complaint rather than a
12
+ * stack trace:
13
+ *
14
+ * - `parameters` is a **flat map of field name to field schema**, not a JSON
15
+ * Schema object with `properties`.
16
+ * - `output` is **required** and pairs a closed `schema` with a `render`
17
+ * function that turns the handler's value into content blocks.
18
+ *
19
+ * The helpers below mirror what the first-party plugins do, so the two front-ends
20
+ * stay readable side by side.
21
+ *
22
+ * @module dsh-ensp/dsh
23
+ */
24
+
25
+ import { readFile, readdir } from 'node:fs/promises'
26
+ import path from 'node:path'
27
+
28
+ import { buildTopology, twoPcLab, twoPcLabConfig, autoLayout } from '../core/build.mjs'
29
+ import { readTopology, writeTopology, summarise } from '../core/topology.mjs'
30
+ import {
31
+ enspPid,
32
+ locateWindow,
33
+ launchEnsp,
34
+ startAllDevices,
35
+ stopAllDevices,
36
+ waitForConsole,
37
+ } from '../core/launcher.mjs'
38
+ import { calibrateAnchors, inspectAnchors } from '../core/calibrate.mjs'
39
+ import { pushConfig, pushConfigMany, queryDevice } from '../core/provision.mjs'
40
+ import { SCENARIOS, renderScenario, searchScenarios, byLevel } from '../core/presets.mjs'
41
+
42
+ /** Declared hard dependency: the tool registry. */
43
+ export const inject = ['tools']
44
+
45
+ export const name = 'ensp'
46
+
47
+ // ---------------------------------------------------------------- schema helpers
48
+ //
49
+ // `req()` marks a field as required for the *author*, and `obj()` compiles the
50
+ // marker away into the JSON-Schema form the validator accepts: a top-level
51
+ // `required` array of names, never a `required: true` on the leaf.
52
+ //
53
+ // Leaving the marker on the leaf fails activation with
54
+ // unsupported JSON schema: schema.properties.x.required is not supported on type "boolean"
55
+
56
+ const str = (description) => ({ type: 'string', ...(description ? { description } : {}) })
57
+ const num = (description) => ({ type: 'number', ...(description ? { description } : {}) })
58
+ const bool = (description) => ({ type: 'boolean', ...(description ? { description } : {}) })
59
+ const arr = (description) => ({ type: 'array', items: { type: 'object' }, ...(description ? { description } : {}) })
60
+
61
+ /** Mark an output field required. The marker is consumed by `obj()`. */
62
+ const req = (node) => Object.assign(node, { required: true })
63
+
64
+ /**
65
+ * Turn an author-facing property map into a closed JSON Schema object,
66
+ * hoisting every leaf `required: true` into the object-level array.
67
+ */
68
+ function compileObject(properties) {
69
+ const compiled = {}
70
+ const required = []
71
+ for (const key of Object.keys(properties ?? {})) {
72
+ const child = properties[key]
73
+ if (child && typeof child === 'object' && child.required === true) required.push(key)
74
+ compiled[key] = stripRequired(child)
75
+ }
76
+ const schema = { type: 'object', additionalProperties: false, properties: compiled }
77
+ if (required.length) schema.required = required
78
+ return schema
79
+ }
80
+
81
+ /** Deep-copy a node without the internal `required` marker. */
82
+ function stripRequired(node) {
83
+ if (node === null || typeof node !== 'object') return node
84
+ if (Array.isArray(node)) return node.map(stripRequired)
85
+ const out = {}
86
+ for (const [key, value] of Object.entries(node)) {
87
+ if (key === 'required') continue
88
+ out[key] = stripRequired(value)
89
+ }
90
+ return out
91
+ }
92
+
93
+ /** Declare a closed output object. */
94
+ const obj = (properties) => compileObject(properties)
95
+
96
+ /** One plain text content block. */
97
+ const text = (value) => [{ type: 'text', text: String(value) }]
98
+
99
+ /** Render a JSON payload for display without depending on any UI helper. */
100
+ const showJson = (value) => text(JSON.stringify(value, null, 2))
101
+
102
+ /**
103
+ * Register one tool with the harness.
104
+ *
105
+ * @param {object} tools - `ctx.tools`
106
+ * @param {object} definition - `{ name, description, parameters, output, execute }`
107
+ */
108
+ function add(tools, definition) {
109
+ const { execute, ...rest } = definition
110
+ return tools.register({
111
+ ...rest,
112
+ async execute(args) {
113
+ return execute(args ?? {})
114
+ },
115
+ })
116
+ }
117
+
118
+ /**
119
+ * Build the plugin.
120
+ *
121
+ * @param {object} ctx - Cordis context carrying the declared services.
122
+ */
123
+ export function apply(ctx) {
124
+ const tools = ctx.tools
125
+ if (!tools?.register) {
126
+ console.warn('[dsh-ensp] tools service unavailable; plugin inactive')
127
+ return
128
+ }
129
+
130
+ /* ------------------------------------------------------------------ status */
131
+
132
+ add(tools, {
133
+ name: 'ensp_status',
134
+ description:
135
+ 'Report eNSP state: whether the client is running, its window handle, and the calibrated toolbar anchors. '
136
+ + 'With a topology path, also polls every device console and reports which ones answer VRP.',
137
+ parameters: {
138
+ topology: str('Optional .topo path; when set, each console is probed (max 4s each).'),
139
+ },
140
+ output: {
141
+ schema: obj({
142
+ running: req(bool()),
143
+ pid: req(num()),
144
+ hwnd: req(str()),
145
+ anchors: req(str()),
146
+ devices: req(str()),
147
+ error: req(str()),
148
+ }),
149
+ render(args, value) {
150
+ if (!value.running) return text('eNSP is not running.')
151
+ const lines = [`eNSP running, pid ${value.pid}, hwnd ${value.hwnd || '(no frame)'}`]
152
+ if (value.anchors) lines.push(value.anchors)
153
+ if (value.devices) lines.push('', value.devices)
154
+ if (value.error) lines.push('', value.error)
155
+ return text(lines.join('\n'))
156
+ },
157
+ },
158
+ async execute({ topology }) {
159
+ const pid = enspPid()
160
+ if (!pid) return { running: false }
161
+ const hwnd = locateWindow(pid)
162
+ let anchors = ''
163
+ if (hwnd) {
164
+ const a = calibrateAnchors(hwnd)
165
+ anchors = `toolbar anchors (${a.method}): start ${JSON.stringify(a.start)}, stop ${JSON.stringify(a.stop)}`
166
+ }
167
+ let devices = ''
168
+ if (topology) {
169
+ const topo = await readTopology(topology)
170
+ const rows = await Promise.all(
171
+ topo.devices
172
+ .filter((d) => d.comPort)
173
+ .map(async (d) => {
174
+ const r = await waitForConsole({ port: d.comPort, timeoutMs: 4000 })
175
+ return `${r.ready ? 'ready' : 'no answer'} ${d.name} :${d.comPort} ${r.prompt ?? ''}`.trimEnd()
176
+ }),
177
+ )
178
+ devices = rows.join('\n')
179
+ }
180
+ return { running: true, pid, hwnd: hwnd ?? '', anchors, devices }
181
+ },
182
+ })
183
+
184
+ /* -------------------------------------------------------------- build topo */
185
+
186
+ add(tools, {
187
+ name: 'ensp_build_topology',
188
+ description:
189
+ 'Generate a .topo file eNSP can open. Use preset "two-pc" for the built-in two-PC lab, '
190
+ + 'or pass devices and links (links use VRP interface names such as GigabitEthernet0/0/0).',
191
+ parameters: {
192
+ path: str('Output .topo path.'),
193
+ preset: str('"two-pc" for the built-in lab.'),
194
+ vlan: num('VLAN id for the two-pc preset (default 10).'),
195
+ devices: arr('Device list: [{ name, model, x, y }].'),
196
+ links: arr('Link list: [{ a, aPort, b, bPort }].'),
197
+ },
198
+ output: {
199
+ schema: obj({
200
+ path: req(str()),
201
+ devices: req(num()),
202
+ links: req(num()),
203
+ consoles: req(str()),
204
+ error: req(str()),
205
+ }),
206
+ render(args, value) {
207
+ if (value.error) return text(value.error)
208
+ return text(
209
+ `wrote ${value.path}\n${value.devices} devices, ${value.links} links\nconsoles: ${value.consoles}`,
210
+ )
211
+ },
212
+ },
213
+ async execute(args) {
214
+ if (!args.path) throw new Error('path is required')
215
+ let topo
216
+ if (args.preset === 'two-pc' || (!args.devices && !args.links)) {
217
+ topo = twoPcLab({ vlan: args.vlan ?? 10 })
218
+ } else {
219
+ const b = buildTopology()
220
+ for (const d of args.devices ?? []) b.add(d.name, d.model, { x: d.x, y: d.y, pc: d.pc })
221
+ for (const l of args.links ?? []) b.link(l.a, l.aPort, l.b, l.bPort)
222
+ topo = autoLayout(b.finish())
223
+ }
224
+ const target = path.resolve(args.path)
225
+ await writeTopology(target, topo)
226
+ const info = summarise(topo)
227
+ return {
228
+ path: target,
229
+ devices: info.devices,
230
+ links: info.links,
231
+ consoles: JSON.stringify(info.consoles),
232
+ }
233
+ },
234
+ })
235
+
236
+ /* ------------------------------------------------------------------- open */
237
+
238
+ add(tools, {
239
+ name: 'ensp_open',
240
+ description: 'Launch the eNSP client (or reuse the running one) and open a topology file.',
241
+ parameters: { topology: str('Absolute path to a .topo file.') },
242
+ output: {
243
+ schema: obj({
244
+ pid: req(num()),
245
+ hwnd: req(str()),
246
+ reused: req(bool()),
247
+ error: req(str()),
248
+ }),
249
+ render(args, value) {
250
+ return text(`eNSP pid ${value.pid}, hwnd ${value.hwnd}${value.reused ? ' (reused)' : ' (launched)'}`)
251
+ },
252
+ },
253
+ async execute({ topology }) {
254
+ if (!topology) throw new Error('topology is required')
255
+ const r = await launchEnsp({ topology: path.resolve(topology) })
256
+ return { pid: r.pid, hwnd: r.hwnd, reused: Boolean(r.reused) }
257
+ },
258
+ })
259
+
260
+ /* ------------------------------------------------------------------ start */
261
+
262
+ add(tools, {
263
+ name: 'ensp_start',
264
+ description:
265
+ 'Boot every device in a topology. Calibrates the toolbar by colour, clicks start, then polls each '
266
+ + 'console until VRP answers, so readiness is measured per device rather than assumed. The window is '
267
+ + 'minimised afterwards unless keepWindow is true.',
268
+ parameters: {
269
+ topology: str('Absolute path to the .topo file that is currently open.'),
270
+ perDeviceTimeoutMs: num('Per-device readiness ceiling in ms (default 240000).'),
271
+ keepWindow: bool('Leave the eNSP window visible after starting.'),
272
+ },
273
+ output: {
274
+ schema: obj({
275
+ allReady: req(bool()),
276
+ ready: req(num()),
277
+ total: req(num()),
278
+ anchors: req(str()),
279
+ devices: req(str()),
280
+ error: req(str()),
281
+ }),
282
+ render(args, value) {
283
+ if (value.error) return text(value.error)
284
+ const head = `${value.ready}/${value.total} devices ready${value.allReady ? '' : ' (some did not answer)'}`
285
+ return text([head, value.anchors, '', value.devices].filter(Boolean).join('\n'))
286
+ },
287
+ },
288
+ async execute(args) {
289
+ if (!args.topology) throw new Error('topology is required')
290
+ const topo = await readTopology(path.resolve(args.topology))
291
+ const hwnd = locateWindow(enspPid())
292
+ if (!hwnd) throw new Error('eNSP window not found; call ensp_open first')
293
+ const r = await startAllDevices({
294
+ devices: topo.devices,
295
+ hwnd,
296
+ perDeviceTimeoutMs: args.perDeviceTimeoutMs ?? 240000,
297
+ minimise: !args.keepWindow,
298
+ })
299
+ return {
300
+ allReady: r.allReady,
301
+ ready: r.ready,
302
+ total: r.total,
303
+ anchors: `toolbar anchors (${r.anchors?.method ?? 'default'}): ${JSON.stringify(r.anchors?.start ?? {})}`,
304
+ devices: r.devices
305
+ .map((d) => `${d.ready ? 'ready' : 'no answer'} ${d.name} :${d.port} ${d.prompt ?? ''}`.trimEnd())
306
+ .join('\n'),
307
+ }
308
+ },
309
+ })
310
+
311
+ /* ------------------------------------------------------------------- stop */
312
+
313
+ add(tools, {
314
+ name: 'ensp_stop',
315
+ description: 'Shut down every device in the running topology.',
316
+ parameters: {},
317
+ output: {
318
+ schema: obj({
319
+ ok: req(bool()),
320
+ anchors: req(str()),
321
+ error: req(str()),
322
+ }),
323
+ render(args, value) {
324
+ if (value.error) return text(value.error)
325
+ return text(`shutdown requested for all devices\n${value.anchors}`)
326
+ },
327
+ },
328
+ async execute() {
329
+ const hwnd = locateWindow(enspPid())
330
+ if (!hwnd) throw new Error('eNSP window not found')
331
+ const r = stopAllDevices(hwnd)
332
+ return { ok: r.ok, anchors: `anchors (${r.anchors?.method ?? 'default'}): ${JSON.stringify(r.anchors?.stop ?? {})}` }
333
+ },
334
+ })
335
+
336
+ /* ------------------------------------------------------------------ apply */
337
+
338
+ add(tools, {
339
+ name: 'ensp_apply',
340
+ description:
341
+ 'Push a configuration script to one device, one command at a time. Each command is confirmed by its '
342
+ + 'prompt before the next is sent; the first error aborts the batch and reports the line, the command '
343
+ + 'and the raw device output.',
344
+ parameters: {
345
+ device: str('Device name, used in the report.'),
346
+ port: num('Console TCP port from the .topo file.'),
347
+ script: str('Configuration text; blank lines and ! / # comments are skipped.'),
348
+ save: bool('Run save at the end.'),
349
+ },
350
+ output: {
351
+ schema: obj({
352
+ ok: req(bool()),
353
+ applied: req(num()),
354
+ total: req(num()),
355
+ saved: req(bool()),
356
+ failedAt: req(str()),
357
+ error: req(str()),
358
+ }),
359
+ render(args, value) {
360
+ if (value.error) return text(value.error)
361
+ if (value.ok) {
362
+ return text(`${args.device}: ${value.applied}/${value.total} commands applied${value.saved ? ', saved' : ''}`)
363
+ }
364
+ return text(`${args.device}: stopped after ${value.applied}/${value.total} commands\n${value.failedAt}`)
365
+ },
366
+ },
367
+ async execute(args) {
368
+ if (!args.port) throw new Error('port is required')
369
+ const r = await pushConfig({
370
+ device: args.device ?? 'device',
371
+ port: args.port,
372
+ script: args.script ?? '',
373
+ save: Boolean(args.save),
374
+ })
375
+ return {
376
+ ok: r.ok,
377
+ applied: r.applied,
378
+ total: r.total,
379
+ saved: r.saved,
380
+ failedAt: r.failedAt
381
+ ? `line ${r.failedAt.line}: ${r.failedAt.command}\n ${r.failedAt.error}\n ${(r.failedAt.output ?? '').trim().split('\n').slice(-6).join('\n ')}`
382
+ : '',
383
+ }
384
+ },
385
+ })
386
+
387
+ /* -------------------------------------------------------------- apply dir */
388
+
389
+ add(tools, {
390
+ name: 'ensp_apply_dir',
391
+ description:
392
+ 'Push one .cfg per device from a directory, matching file names to device names. Devices are '
393
+ + 'configured concurrently and one failure does not stop the others.',
394
+ parameters: {
395
+ directory: str('Directory containing <device>.cfg files.'),
396
+ topology: str('Topology that maps device names to console ports.'),
397
+ save: bool('Run save on each device afterwards.'),
398
+ concurrency: num('How many devices to configure at once (default 3).'),
399
+ },
400
+ output: {
401
+ schema: obj({
402
+ configured: req(num()),
403
+ total: req(num()),
404
+ results: req(str()),
405
+ error: req(str()),
406
+ }),
407
+ render(args, value) {
408
+ if (value.error) return text(value.error)
409
+ return text(`${value.configured}/${value.total} devices configured\n\n${value.results}`)
410
+ },
411
+ },
412
+ async execute(args) {
413
+ if (!args.directory || !args.topology) throw new Error('directory and topology are required')
414
+ const topo = await readTopology(path.resolve(args.topology))
415
+ const consoles = new Map(topo.devices.filter((d) => d.comPort).map((d) => [d.name, d.comPort]))
416
+ const files = (await readdir(args.directory)).filter((f) => /\.(cfg|txt)$/i.test(f))
417
+ const targets = []
418
+ for (const file of files) {
419
+ const device = file.replace(/\.(cfg|txt)$/i, '')
420
+ const port = consoles.get(device)
421
+ if (!port) continue
422
+ targets.push({ device, port, script: await readFile(path.join(args.directory, file), 'utf8') })
423
+ }
424
+ const results = await pushConfigMany({
425
+ targets,
426
+ concurrency: args.concurrency ?? 3,
427
+ save: Boolean(args.save),
428
+ })
429
+ return {
430
+ configured: results.filter((r) => r.ok).length,
431
+ total: results.length,
432
+ results: results
433
+ .map((r) =>
434
+ r.ok
435
+ ? `ok ${r.device} ${r.applied}/${r.total}`
436
+ : `FAIL ${r.device} line ${r.failedAt?.line}: ${r.failedAt?.command} -> ${r.failedAt?.error}`,
437
+ )
438
+ .join('\n'),
439
+ }
440
+ },
441
+ })
442
+
443
+ /* ------------------------------------------------------------------ query */
444
+
445
+ add(tools, {
446
+ name: 'ensp_query',
447
+ description: 'Run one read-only command on a device console and return its output.',
448
+ parameters: {
449
+ port: num('Console TCP port.'),
450
+ command: str('One VRP command, e.g. "display ip routing-table".'),
451
+ },
452
+ output: {
453
+ schema: obj({
454
+ ok: req(bool()),
455
+ output: req(str()),
456
+ error: req(str()),
457
+ }),
458
+ render(args, value) {
459
+ if (!value.ok) return text(`${args.command} failed: ${value.error ?? 'unknown error'}`)
460
+ return text(value.output)
461
+ },
462
+ },
463
+ async execute(args) {
464
+ if (!args.port || !args.command) throw new Error('port and command are required')
465
+ const r = await queryDevice({ port: args.port, command: args.command })
466
+ return { ok: r.ok, output: r.output, error: r.error ?? '' }
467
+ },
468
+ })
469
+
470
+ /* -------------------------------------------------------------- scenarios */
471
+
472
+ add(tools, {
473
+ name: 'ensp_scenarios',
474
+ description:
475
+ 'List or search the 23 built-in configuration scenarios. Each is a skeleton: what to create, in what '
476
+ + 'order, and the command that proves it worked, rather than a command dump.',
477
+ parameters: {
478
+ search: str('Keyword filter, e.g. "ospf" or "vlan".'),
479
+ level: str('One of basic, intermediate, advanced.'),
480
+ },
481
+ output: {
482
+ schema: obj({
483
+ count: req(num()),
484
+ list: req(str()),
485
+ error: req(str()),
486
+ }),
487
+ render(args, value) {
488
+ return text(value.list || '(no scenario matched)')
489
+ },
490
+ },
491
+ async execute({ search, level }) {
492
+ const groups = byLevel()
493
+ let ids = level && groups[level] ? groups[level] : Object.keys(SCENARIOS)
494
+ if (search) {
495
+ const matched = new Set(searchScenarios(search).map((h) => h.id))
496
+ ids = ids.filter((id) => matched.has(id))
497
+ }
498
+ return {
499
+ count: ids.length,
500
+ list: ids
501
+ .map((id) => `${id.padEnd(24)} ${SCENARIOS[id].title} [${SCENARIOS[id].level}]`)
502
+ .join('\n'),
503
+ }
504
+ },
505
+ })
506
+
507
+ add(tools, {
508
+ name: 'ensp_scenario',
509
+ description: 'Return one scenario as a step-by-step brief with verification commands and pitfalls.',
510
+ parameters: { id: str('Scenario id, e.g. "ospf-single".') },
511
+ output: {
512
+ schema: obj({
513
+ id: req(str()),
514
+ brief: req(str()),
515
+ error: req(str()),
516
+ }),
517
+ render(args, value) {
518
+ return text(value.brief)
519
+ },
520
+ },
521
+ async execute({ id }) {
522
+ if (!SCENARIOS[id]) throw new Error(`unknown scenario "${id}"`)
523
+ return { id, brief: renderScenario(id) }
524
+ },
525
+ })
526
+
527
+ /* ---------------------------------------------------------- preset config */
528
+
529
+ add(tools, {
530
+ name: 'ensp_preset_config',
531
+ description: 'Return the configuration text for the built-in two-PC lab.',
532
+ parameters: { vlan: num('VLAN id (default 10).') },
533
+ output: {
534
+ schema: obj({
535
+ script: req(str()),
536
+ error: req(str()),
537
+ }),
538
+ render(args, value) {
539
+ return text(value.script)
540
+ },
541
+ },
542
+ async execute({ vlan }) {
543
+ return { script: twoPcLabConfig({ vlan: vlan ?? 10 }) }
544
+ },
545
+ })
546
+
547
+ /* ------------------------------------------------------------------ probe */
548
+
549
+ add(tools, {
550
+ name: 'ensp_probe',
551
+ description:
552
+ 'Re-measure the toolbar anchors and report every colour cluster. Run this after an eNSP upgrade or a '
553
+ + 'change of display scaling; results are cached per window size otherwise.',
554
+ parameters: {},
555
+ output: {
556
+ schema: obj({
557
+ window: req(str()),
558
+ start: req(str()),
559
+ stop: req(str()),
560
+ method: req(str()),
561
+ clusters: req(str()),
562
+ error: req(str()),
563
+ }),
564
+ render(args, value) {
565
+ if (value.error) return text(value.error)
566
+ return text(
567
+ `window ${value.window}\nanchors (${value.method}): start ${value.start}, stop ${value.stop}\n\n${value.clusters}`,
568
+ )
569
+ },
570
+ },
571
+ async execute() {
572
+ const hwnd = locateWindow(enspPid())
573
+ if (!hwnd) throw new Error('eNSP window not found')
574
+ const r = inspectAnchors(hwnd)
575
+ const resolved = r.resolved ?? { start: {}, stop: {}, method: 'none' }
576
+ return {
577
+ window: r.window ?? '(unknown)',
578
+ start: JSON.stringify(resolved.start),
579
+ stop: JSON.stringify(resolved.stop),
580
+ method: resolved.method,
581
+ clusters: [
582
+ `green (${r.green.length}): ` + r.green.map((c) => `${c.x},${c.y} ${c.w}x${c.h}`).join(' | '),
583
+ `red (${r.red.length}): ` + r.red.map((c) => `${c.x},${c.y} ${c.w}x${c.h}`).join(' | '),
584
+ ].join('\n'),
585
+ }
586
+ },
587
+ })
588
+ }
589
+
590
+ /** Model metadata for the plugin row. */
591
+ export const dsh = {
592
+ name: 'dsh-ensp',
593
+ version: '0.1.5',
594
+ description: 'Automate Huawei eNSP labs: build topology, boot devices, push config with verification.',
595
+ }