Files
zeekay 1b367e2b62 chore: rebase the tree onto upstream master
The tree was a debranded snapshot of upstream taken around 2026-02-20, and
upstream has moved about five months since. This replaces it with upstream
master, mechanically re-derived: bin/debrand does the rename, and the
pipeline and squashed migrations are restored from the shipped tree.

Not a merge. The previous import replaced the tree wholesale in a single
commit, so there is no shared content history to merge against. Reproducing
the rename on the new tree is what actually converges.

What survives: .hanzo/workflows, the Dockerfiles and bin/docker* entrypoints,
compose files, LLM.md, and insights/migrations. The squashed initial
migration is load-bearing — the live database records it by name, so
upstream's ~918 individual migrations cannot come back.

Nine products are dropped as unreferenced. Twenty-one more that we do not run
are kept anyway because the core app still imports them; unpicking those is a
refactor and does not belong in a rebase.

This base does not build yet, and the reasons are not in the rename:
upstream now wants Python 3.13.13 against our 3.12.12 and Django 5.2 against
our 4.2, and the dependency manifests still name upstream packages that we
republish under our own names. Those are decisions, not renames.
2026-08-01 15:44:40 -07:00

226 lines
7.0 KiB
Go

package config
import (
"fmt"
"os"
"path/filepath"
"sort"
"gopkg.in/yaml.v3"
)
// CapabilityGroupKey is the reserved key under ProcConfig.Groups that carries
// the capability dimension inferred from ProcConfig.Capability
const CapabilityGroupKey = "capability"
// Mirrors a single entry under mprocs.yaml's "procs" key
type ProcConfig struct {
Shell string `yaml:"shell"`
Capability string `yaml:"capability"`
Cmd []string `yaml:"cmd"`
Cwd string `yaml:"cwd"`
Autostart *bool `yaml:"autostart"`
Autorestart bool `yaml:"autorestart"`
Stop string `yaml:"stop"` // "SIGINT", "SIGTERM", "SIGKILL", or "hard-kill"
AskSkip bool `yaml:"ask_skip"`
Env map[string]string `yaml:"env"`
ReadyPattern string `yaml:"ready_pattern"`
Groups map[string]string `yaml:"groups"` // user-defined grouping dimensions, using map here so new dimensions need no code changes
}
// Reports whether the process should start automatically
// Defaults to true when the field is absent from the YAML
func (p ProcConfig) ShouldAutostart() bool {
return p.Autostart == nil || *p.Autostart
}
// Top-level mprocs.yaml document
type Config struct {
Shell string `yaml:"shell"`
Procs map[string]ProcConfig `yaml:"procs"`
GroupOrder map[string][]string `yaml:"group_order"` // display order per dimension
DefaultGroup string `yaml:"default_group"` // grouping dimension active on startup ("" = ungrouped)
HideKeymapWindow bool `yaml:"hide_keymap_window"`
MouseScrollSpeed int `yaml:"mouse_scroll_speed"`
ProcListWidth int `yaml:"proc_list_width"`
Scrollback int `yaml:"scrollback"`
}
// ResolveConfigPath returns the config file path to use. If explicit is
// non-empty it is returned as-is. Otherwise, the function checks for an
// mprocs.yaml in the current directory and returns its path, or an error
// if no config can be found.
func ResolveConfigPath(explicit string) (string, error) {
if explicit != "" {
return explicit, nil
}
info, err := os.Stat("mprocs.yaml")
if err == nil && info.Mode().IsRegular() {
return "mprocs.yaml", nil
}
if err == nil {
return "", fmt.Errorf("mprocs.yaml exists but is not a regular file")
}
if os.IsNotExist(err) {
return "", fmt.Errorf("no config: pass --config or place an mprocs.yaml in the current directory")
}
return "", fmt.Errorf("stat mprocs.yaml: %w", err)
}
// Reads and parses an mprocs-compatible YAML config file
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
if cfg.Scrollback == 0 {
cfg.Scrollback = 10_000
}
if cfg.MouseScrollSpeed == 0 {
cfg.MouseScrollSpeed = 3
}
inferGroupFromCapability(&cfg)
return &cfg, nil
}
// inferGroupFromCapability copies each proc's "capability:" field into
// "groups:" map, so the TUI's grouping dimension cycle ("g" key) picks
// it up alongside user-declared dimensions.
// An explicit Groups[CapabilityGroupKey] in YAML takes precedence.
func inferGroupFromCapability(cfg *Config) {
for name, pc := range cfg.Procs {
if pc.Capability == "" {
// Procs without a capability fall under "Ungrouped"
continue
}
if _, ok := pc.Groups[CapabilityGroupKey]; ok {
// Any explicit entry is respected, setting to "" leads to "Ungrouped"
continue
}
if pc.Groups == nil {
pc.Groups = make(map[string]string)
}
pc.Groups[CapabilityGroupKey] = pc.Capability
cfg.Procs[name] = pc
}
}
// Intent is a minimal representation of an intent from intent-map.yaml.
type Intent struct {
Name string
Description string
}
// IntentMapConfig holds just enough of intent-map.yaml to display intents in the TUI.
type IntentMapConfig struct {
Intents []Intent
}
// LoadIntentMap finds and parses devenv/intent-map.yaml by walking up from
// the working directory. Returns the parsed intents sorted by name.
func LoadIntentMap() (*IntentMapConfig, error) {
path, err := findIntentMapPath()
if err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var raw struct {
Intents map[string]struct {
Description string `yaml:"description"`
} `yaml:"intents"`
}
if err := yaml.Unmarshal(data, &raw); err != nil {
return nil, err
}
if raw.Intents == nil {
return nil, fmt.Errorf("intent-map missing 'intents' mapping")
}
intents := make([]Intent, 0, len(raw.Intents))
for name, fields := range raw.Intents {
intents = append(intents, Intent{Name: name, Description: fields.Description})
}
sort.Slice(intents, func(i, j int) bool { return intents[i].Name < intents[j].Name })
return &IntentMapConfig{Intents: intents}, nil
}
// findFileUpward walks up from the working directory looking for relPath.
// Returns the absolute path if found, or an error if the filesystem root
// is reached without a match.
func findFileUpward(relPath string) (string, error) {
dir, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("get working directory: %w", err)
}
for {
candidate := filepath.Join(dir, relPath)
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", fmt.Errorf("%s not found", relPath)
}
dir = parent
}
}
func findIntentMapPath() (string, error) {
return findFileUpward(filepath.Join("devenv", "intent-map.yaml"))
}
// InsightsConfig represents the _insights section embedded in generated mprocs configs.
type InsightsConfig struct {
Intents []string `yaml:"intents"`
ExcludeUnits []string `yaml:"exclude_units"`
}
// LoadInsightsConfig reads a generated mprocs.yaml and extracts the _insights section.
// Returns nil (no error) if the section is absent.
func LoadInsightsConfig(configPath string) (*InsightsConfig, error) {
data, err := os.ReadFile(configPath)
if err != nil {
return nil, err
}
var wrapper struct {
Insights *InsightsConfig `yaml:"_insights"`
}
if err := yaml.Unmarshal(data, &wrapper); err != nil {
return nil, err
}
return wrapper.Insights, nil
}
// LoadRegistry finds and parses the full process registry (bin/mprocs.yaml)
// by walking up from the working directory. Returns nil (no error) if the
// registry cannot be found.
func LoadRegistry() (*Config, error) {
path, err := findFileUpward(filepath.Join("bin", "mprocs.yaml"))
if err != nil {
return nil, nil
}
return Load(path)
}
// OrderedNames returns process names in a stable, predictable order.
// "info" is always first (if present), then remaining names sorted alphabetically.
func (c *Config) OrderedNames() []string {
names := make([]string, 0, len(c.Procs))
for name := range c.Procs {
if name != "info" {
names = append(names, name)
}
}
sort.Strings(names)
if _, ok := c.Procs["info"]; ok {
names = append([]string{"info"}, names...)
}
return names
}