feat: add Cisco IOS shell with mode state machine and abbreviation matching (PLAN.md 3.2)
Implements a Cisco IOS CLI emulator with four modes (user exec, privileged exec, global config, interface config), Cisco-style command abbreviation (e.g. sh run, conf t), enable password flow, and realistic show command output including running-config, interfaces, IP routes, and VLANs. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
414
internal/shell/cisco/commands.go
Normal file
414
internal/shell/cisco/commands.go
Normal file
@@ -0,0 +1,414 @@
|
||||
package cisco
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// commandResult holds the output of a command and whether the session should end.
|
||||
type commandResult struct {
|
||||
output string
|
||||
exit bool
|
||||
}
|
||||
|
||||
// commandEntry defines a single command with its name and optional sub-commands.
|
||||
type commandEntry struct {
|
||||
name string
|
||||
subs []commandEntry // nil for leaf commands
|
||||
}
|
||||
|
||||
// userExecCommands defines the command tree for user EXEC mode.
|
||||
var userExecCommands = []commandEntry{
|
||||
{name: "show", subs: []commandEntry{
|
||||
{name: "version"},
|
||||
{name: "clock"},
|
||||
{name: "ip", subs: []commandEntry{
|
||||
{name: "route"},
|
||||
{name: "interface", subs: []commandEntry{
|
||||
{name: "brief"},
|
||||
}},
|
||||
}},
|
||||
{name: "interfaces"},
|
||||
{name: "vlan", subs: []commandEntry{
|
||||
{name: "brief"},
|
||||
}},
|
||||
}},
|
||||
{name: "enable"},
|
||||
{name: "exit"},
|
||||
{name: "?"},
|
||||
}
|
||||
|
||||
// privilegedExecCommands extends user commands for privileged mode.
|
||||
var privilegedExecCommands = []commandEntry{
|
||||
{name: "show", subs: []commandEntry{
|
||||
{name: "version"},
|
||||
{name: "clock"},
|
||||
{name: "ip", subs: []commandEntry{
|
||||
{name: "route"},
|
||||
{name: "interface", subs: []commandEntry{
|
||||
{name: "brief"},
|
||||
}},
|
||||
}},
|
||||
{name: "interfaces"},
|
||||
{name: "running-config"},
|
||||
{name: "startup-config"},
|
||||
{name: "vlan", subs: []commandEntry{
|
||||
{name: "brief"},
|
||||
}},
|
||||
}},
|
||||
{name: "configure", subs: []commandEntry{
|
||||
{name: "terminal"},
|
||||
}},
|
||||
{name: "write", subs: []commandEntry{
|
||||
{name: "memory"},
|
||||
}},
|
||||
{name: "copy"},
|
||||
{name: "reload"},
|
||||
{name: "disable"},
|
||||
{name: "terminal", subs: []commandEntry{
|
||||
{name: "length"},
|
||||
}},
|
||||
{name: "exit"},
|
||||
{name: "?"},
|
||||
}
|
||||
|
||||
// globalConfigCommands defines the command tree for global config mode.
|
||||
var globalConfigCommands = []commandEntry{
|
||||
{name: "hostname"},
|
||||
{name: "interface"},
|
||||
{name: "ip", subs: []commandEntry{
|
||||
{name: "route"},
|
||||
}},
|
||||
{name: "no"},
|
||||
{name: "end"},
|
||||
{name: "exit"},
|
||||
{name: "?"},
|
||||
}
|
||||
|
||||
// interfaceConfigCommands defines the command tree for interface config mode.
|
||||
var interfaceConfigCommands = []commandEntry{
|
||||
{name: "ip", subs: []commandEntry{
|
||||
{name: "address"},
|
||||
}},
|
||||
{name: "description"},
|
||||
{name: "shutdown"},
|
||||
{name: "no", subs: []commandEntry{
|
||||
{name: "shutdown"},
|
||||
}},
|
||||
{name: "switchport", subs: []commandEntry{
|
||||
{name: "mode"},
|
||||
}},
|
||||
{name: "end"},
|
||||
{name: "exit"},
|
||||
{name: "?"},
|
||||
}
|
||||
|
||||
// commandsForMode returns the command tree for the given IOS mode.
|
||||
func commandsForMode(mode iosMode) []commandEntry {
|
||||
switch mode {
|
||||
case modeUserExec:
|
||||
return userExecCommands
|
||||
case modePrivilegedExec:
|
||||
return privilegedExecCommands
|
||||
case modeGlobalConfig:
|
||||
return globalConfigCommands
|
||||
case modeInterfaceConfig:
|
||||
return interfaceConfigCommands
|
||||
default:
|
||||
return userExecCommands
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAbbreviation attempts to match an abbreviated word against a list of
|
||||
// command entries. It returns the matched entry name, or an error string if
|
||||
// ambiguous or unknown.
|
||||
func resolveAbbreviation(word string, entries []commandEntry) (string, error) {
|
||||
word = strings.ToLower(word)
|
||||
var matches []string
|
||||
for _, e := range entries {
|
||||
if strings.ToLower(e.name) == word {
|
||||
return e.name, nil // exact match
|
||||
}
|
||||
if strings.HasPrefix(strings.ToLower(e.name), word) {
|
||||
matches = append(matches, e.name)
|
||||
}
|
||||
}
|
||||
switch len(matches) {
|
||||
case 0:
|
||||
return "", fmt.Errorf("unknown")
|
||||
case 1:
|
||||
return matches[0], nil
|
||||
default:
|
||||
return "", fmt.Errorf("ambiguous")
|
||||
}
|
||||
}
|
||||
|
||||
// resolveCommand resolves a sequence of abbreviated words into the canonical
|
||||
// command path (e.g., ["sh", "run"] → ["show", "running-config"]).
|
||||
// It returns the resolved path, any remaining arguments, and an error if
|
||||
// resolution fails.
|
||||
func resolveCommand(words []string, entries []commandEntry) ([]string, []string, error) {
|
||||
var resolved []string
|
||||
current := entries
|
||||
|
||||
for i, w := range words {
|
||||
name, err := resolveAbbreviation(w, current)
|
||||
if err != nil {
|
||||
if err.Error() == "unknown" && len(resolved) > 0 {
|
||||
// Remaining words are arguments to the resolved command.
|
||||
return resolved, words[i:], nil
|
||||
}
|
||||
return resolved, words[i:], err
|
||||
}
|
||||
resolved = append(resolved, name)
|
||||
|
||||
// Find sub-commands for the matched entry.
|
||||
var nextLevel []commandEntry
|
||||
for _, e := range current {
|
||||
if e.name == name {
|
||||
nextLevel = e.subs
|
||||
break
|
||||
}
|
||||
}
|
||||
if nextLevel == nil {
|
||||
// Leaf command — rest are arguments.
|
||||
return resolved, words[i+1:], nil
|
||||
}
|
||||
current = nextLevel
|
||||
}
|
||||
return resolved, nil, nil
|
||||
}
|
||||
|
||||
// dispatch processes a command line in the context of the current IOS state.
|
||||
func (s *iosState) dispatch(input string) commandResult {
|
||||
words := strings.Fields(input)
|
||||
if len(words) == 0 {
|
||||
return commandResult{}
|
||||
}
|
||||
|
||||
// Handle "?" as a help request.
|
||||
if words[0] == "?" {
|
||||
return s.cmdHelp()
|
||||
}
|
||||
|
||||
cmds := commandsForMode(s.mode)
|
||||
resolved, args, err := resolveCommand(words, cmds)
|
||||
if err != nil {
|
||||
if err.Error() == "ambiguous" {
|
||||
return commandResult{output: fmt.Sprintf("%% Ambiguous command: \"%s\"", input)}
|
||||
}
|
||||
return commandResult{output: invalidInput(input)}
|
||||
}
|
||||
|
||||
if len(resolved) == 0 {
|
||||
return commandResult{output: invalidInput(input)}
|
||||
}
|
||||
|
||||
cmd := strings.Join(resolved, " ")
|
||||
|
||||
switch s.mode {
|
||||
case modeUserExec:
|
||||
return s.dispatchUserExec(cmd, args)
|
||||
case modePrivilegedExec:
|
||||
return s.dispatchPrivilegedExec(cmd, args)
|
||||
case modeGlobalConfig:
|
||||
return s.dispatchGlobalConfig(cmd, args)
|
||||
case modeInterfaceConfig:
|
||||
return s.dispatchInterfaceConfig(cmd, args)
|
||||
}
|
||||
return commandResult{output: invalidInput(input)}
|
||||
}
|
||||
|
||||
func (s *iosState) dispatchUserExec(cmd string, args []string) commandResult {
|
||||
switch cmd {
|
||||
case "show version":
|
||||
return commandResult{output: showVersion(s)}
|
||||
case "show clock":
|
||||
return commandResult{output: showClock()}
|
||||
case "show ip route":
|
||||
return commandResult{output: showIPRoute(s)}
|
||||
case "show ip interface brief":
|
||||
return commandResult{output: showIPInterfaceBrief(s)}
|
||||
case "show interfaces":
|
||||
return commandResult{output: showInterfaces(s)}
|
||||
case "show vlan brief":
|
||||
return commandResult{output: showVLANBrief()}
|
||||
case "enable":
|
||||
return commandResult{} // handled in Handle() loop
|
||||
case "exit":
|
||||
return commandResult{exit: true}
|
||||
}
|
||||
return commandResult{output: invalidInput(cmd)}
|
||||
}
|
||||
|
||||
func (s *iosState) dispatchPrivilegedExec(cmd string, args []string) commandResult {
|
||||
switch cmd {
|
||||
case "show version":
|
||||
return commandResult{output: showVersion(s)}
|
||||
case "show clock":
|
||||
return commandResult{output: showClock()}
|
||||
case "show ip route":
|
||||
return commandResult{output: showIPRoute(s)}
|
||||
case "show ip interface brief":
|
||||
return commandResult{output: showIPInterfaceBrief(s)}
|
||||
case "show interfaces":
|
||||
return commandResult{output: showInterfaces(s)}
|
||||
case "show running-config":
|
||||
return commandResult{output: showRunningConfig(s)}
|
||||
case "show startup-config":
|
||||
return commandResult{output: showRunningConfig(s)} // same as running
|
||||
case "show vlan brief":
|
||||
return commandResult{output: showVLANBrief()}
|
||||
case "configure terminal":
|
||||
s.mode = modeGlobalConfig
|
||||
return commandResult{output: "Enter configuration commands, one per line. End with CNTL/Z."}
|
||||
case "write memory":
|
||||
return commandResult{output: "[OK]"}
|
||||
case "copy":
|
||||
return commandResult{output: "[OK]"}
|
||||
case "reload":
|
||||
return commandResult{output: "System configuration has been modified. Save? [yes/no]: ", exit: true}
|
||||
case "disable":
|
||||
s.mode = modeUserExec
|
||||
return commandResult{}
|
||||
case "terminal length":
|
||||
return commandResult{} // accept silently
|
||||
case "exit":
|
||||
return commandResult{exit: true}
|
||||
}
|
||||
return commandResult{output: invalidInput(cmd)}
|
||||
}
|
||||
|
||||
func (s *iosState) dispatchGlobalConfig(cmd string, args []string) commandResult {
|
||||
switch cmd {
|
||||
case "hostname":
|
||||
if len(args) < 1 {
|
||||
return commandResult{output: "% Incomplete command."}
|
||||
}
|
||||
s.hostname = args[0]
|
||||
return commandResult{}
|
||||
case "interface":
|
||||
if len(args) < 1 {
|
||||
return commandResult{output: "% Incomplete command."}
|
||||
}
|
||||
ifName := strings.Join(args, "")
|
||||
s.currentIf = ifName
|
||||
s.mode = modeInterfaceConfig
|
||||
return commandResult{}
|
||||
case "ip route":
|
||||
return commandResult{} // accept silently
|
||||
case "no":
|
||||
return commandResult{} // accept silently
|
||||
case "end":
|
||||
s.mode = modePrivilegedExec
|
||||
return commandResult{}
|
||||
case "exit":
|
||||
s.mode = modePrivilegedExec
|
||||
return commandResult{}
|
||||
}
|
||||
return commandResult{output: invalidInput(cmd)}
|
||||
}
|
||||
|
||||
func (s *iosState) dispatchInterfaceConfig(cmd string, args []string) commandResult {
|
||||
switch cmd {
|
||||
case "ip address":
|
||||
if len(args) < 2 {
|
||||
return commandResult{output: "% Incomplete command."}
|
||||
}
|
||||
if iface := s.findInterface(s.currentIf); iface != nil {
|
||||
iface.ip = args[0]
|
||||
iface.mask = args[1]
|
||||
}
|
||||
return commandResult{}
|
||||
case "description":
|
||||
if len(args) < 1 {
|
||||
return commandResult{output: "% Incomplete command."}
|
||||
}
|
||||
if iface := s.findInterface(s.currentIf); iface != nil {
|
||||
iface.desc = strings.Join(args, " ")
|
||||
}
|
||||
return commandResult{}
|
||||
case "shutdown":
|
||||
if iface := s.findInterface(s.currentIf); iface != nil {
|
||||
iface.shutdown = true
|
||||
iface.status = "administratively down"
|
||||
iface.protocol = "down"
|
||||
}
|
||||
return commandResult{}
|
||||
case "no shutdown":
|
||||
if iface := s.findInterface(s.currentIf); iface != nil {
|
||||
iface.shutdown = false
|
||||
iface.status = "up"
|
||||
iface.protocol = "up"
|
||||
}
|
||||
return commandResult{}
|
||||
case "switchport mode":
|
||||
return commandResult{} // accept silently
|
||||
case "end":
|
||||
s.mode = modePrivilegedExec
|
||||
s.currentIf = ""
|
||||
return commandResult{}
|
||||
case "exit":
|
||||
s.mode = modeGlobalConfig
|
||||
s.currentIf = ""
|
||||
return commandResult{}
|
||||
}
|
||||
return commandResult{output: invalidInput(cmd)}
|
||||
}
|
||||
|
||||
func (s *iosState) cmdHelp() commandResult {
|
||||
cmds := commandsForMode(s.mode)
|
||||
var b strings.Builder
|
||||
for _, e := range cmds {
|
||||
if e.name == "?" {
|
||||
continue
|
||||
}
|
||||
b.WriteString(fmt.Sprintf(" %-20s %s\n", e.name, helpText(e.name)))
|
||||
}
|
||||
return commandResult{output: b.String()}
|
||||
}
|
||||
|
||||
func helpText(name string) string {
|
||||
switch name {
|
||||
case "show":
|
||||
return "Show running system information"
|
||||
case "enable":
|
||||
return "Turn on privileged commands"
|
||||
case "disable":
|
||||
return "Turn off privileged commands"
|
||||
case "exit":
|
||||
return "Exit from the EXEC"
|
||||
case "configure":
|
||||
return "Enter configuration mode"
|
||||
case "write":
|
||||
return "Write running configuration to memory"
|
||||
case "copy":
|
||||
return "Copy from one file to another"
|
||||
case "reload":
|
||||
return "Halt and perform a cold restart"
|
||||
case "terminal":
|
||||
return "Set terminal line parameters"
|
||||
case "hostname":
|
||||
return "Set system's network name"
|
||||
case "interface":
|
||||
return "Select an interface to configure"
|
||||
case "ip":
|
||||
return "Global IP configuration subcommands"
|
||||
case "no":
|
||||
return "Negate a command or set its defaults"
|
||||
case "end":
|
||||
return "Exit from configure mode"
|
||||
case "description":
|
||||
return "Interface specific description"
|
||||
case "shutdown":
|
||||
return "Shutdown the selected interface"
|
||||
case "switchport":
|
||||
return "Set switching mode characteristics"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func invalidInput(input string) string {
|
||||
return fmt.Sprintf("%% Invalid input detected at '^' marker.\n\n%s\n^", input)
|
||||
}
|
||||
Reference in New Issue
Block a user