feat: add shell interface, registry, and bash shell emulator
Implement Phase 1.4: replaces the hardcoded banner/timeout stub with a proper shell system. Adds a Shell interface with weighted registry for shell selection, a RecordingChannel wrapper (pass-through for now, prep for Phase 2.3 replay), and a bash-like shell with fake filesystem, terminal line reader, and command handling (pwd, ls, cd, cat, whoami, hostname, id, uname, exit). Sessions now log command/output pairs to the store and record the shell name. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
84
internal/shell/registry.go
Normal file
84
internal/shell/registry.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package shell
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type registryEntry struct {
|
||||
shell Shell
|
||||
weight int
|
||||
}
|
||||
|
||||
// Registry holds shells with associated weights for random selection.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
entries []registryEntry
|
||||
}
|
||||
|
||||
// NewRegistry returns an empty Registry.
|
||||
func NewRegistry() *Registry {
|
||||
return &Registry{}
|
||||
}
|
||||
|
||||
// Register adds a shell with the given weight. Weight must be >= 1 and
|
||||
// no duplicate names are allowed.
|
||||
func (r *Registry) Register(shell Shell, weight int) error {
|
||||
if weight < 1 {
|
||||
return fmt.Errorf("weight must be >= 1, got %d", weight)
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
for _, e := range r.entries {
|
||||
if e.shell.Name() == shell.Name() {
|
||||
return fmt.Errorf("shell %q already registered", shell.Name())
|
||||
}
|
||||
}
|
||||
|
||||
r.entries = append(r.entries, registryEntry{shell: shell, weight: weight})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Select picks a shell using weighted random selection.
|
||||
func (r *Registry) Select() (Shell, error) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
if len(r.entries) == 0 {
|
||||
return nil, errors.New("no shells registered")
|
||||
}
|
||||
|
||||
total := 0
|
||||
for _, e := range r.entries {
|
||||
total += e.weight
|
||||
}
|
||||
|
||||
pick := rand.IntN(total)
|
||||
cumulative := 0
|
||||
for _, e := range r.entries {
|
||||
cumulative += e.weight
|
||||
if pick < cumulative {
|
||||
return e.shell, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Should never reach here, but return last entry as fallback.
|
||||
return r.entries[len(r.entries)-1].shell, nil
|
||||
}
|
||||
|
||||
// Get returns a shell by name.
|
||||
func (r *Registry) Get(name string) (Shell, bool) {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
for _, e := range r.entries {
|
||||
if e.shell.Name() == name {
|
||||
return e.shell, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
Reference in New Issue
Block a user