From 2fdb7bb8fad02b952b449879b59432ded33eb4b4 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 20 Aug 2026 13:06:06 -0700 Subject: [PATCH 1/2] Add hey setup omarchy and the bar unread indicator hey setup omarchy installs hey-cli into the Omarchy desktop: a HEY TUI launcher entry under app-id org.omarchy.hey, a HEY row in the SUPER+SPACE menu, an inline hey-unread command module on the bar, and a hey.toml.tpl theme template so theme authors can tune the TUI's accent overlay. Every step is idempotent, reported separately, and --remove reverses them all. The keybinding is printed, never written -- bindings.lua stays the user's. hey omarchy bar-status (hidden) is what the bar module runs every three minutes: a Waybar-style JSON object when the Imbox has unread mail and nothing otherwise. An indicator, not a count -- a number is the attention treadmill HEY exists to end. Logged out or offline also prints nothing and exits 0, because a bar is no place for an error message. Every surface -- launcher, menu row, bar click, suggested keybinding -- shares one app-id so they all focus the same window. --- .surface | 2 + internal/apierr/apierr.go | 5 + internal/cmd/accounts_test.go | 1 + internal/cmd/local_config_trust.go | 15 + internal/cmd/omarchy.go | 849 ++++++++++++++++++++++++++ internal/cmd/omarchy/hey.toml.tpl | 17 + internal/cmd/omarchy_test.go | 919 +++++++++++++++++++++++++++++ internal/cmd/root.go | 18 +- internal/cmd/setup.go | 4 +- internal/config/config.go | 24 +- internal/output/writer.go | 1 + 11 files changed, 1850 insertions(+), 5 deletions(-) create mode 100644 internal/cmd/omarchy.go create mode 100644 internal/cmd/omarchy/hey.toml.tpl create mode 100644 internal/cmd/omarchy_test.go diff --git a/.surface b/.surface index 3a7cedfd..7d78570d 100644 --- a/.surface +++ b/.surface @@ -157,6 +157,8 @@ hey search --to hey search filters hey seen hey setup +hey setup omarchy +hey setup omarchy --remove hey skill hey skill install hey spam diff --git a/internal/apierr/apierr.go b/internal/apierr/apierr.go index 890df7eb..f63951ad 100644 --- a/internal/apierr/apierr.go +++ b/internal/apierr/apierr.go @@ -14,6 +14,11 @@ type Error struct { HTTPStatus int Retryable bool Cause error + + // Meta carries structured context into the JSON error envelope — e.g. the + // per-step results of a partially failed setup, which a scripting caller + // needs to know what did land. + Meta map[string]any } func (e *Error) Error() string { diff --git a/internal/cmd/accounts_test.go b/internal/cmd/accounts_test.go index d28584c5..1b94f582 100644 --- a/internal/cmd/accounts_test.go +++ b/internal/cmd/accounts_test.go @@ -28,6 +28,7 @@ func TestCommandAccountScopePolicy(t *testing.T) { {args: []string{"accounts", "list"}, want: false}, {args: []string{"auth", "status"}, want: false}, {args: []string{"config", "show"}, want: false}, + {args: []string{"omarchy", "bar-status"}, want: false}, } { command, _, err := root.Find(test.args) if err != nil { diff --git a/internal/cmd/local_config_trust.go b/internal/cmd/local_config_trust.go index 01950d0e..969fa6b2 100644 --- a/internal/cmd/local_config_trust.go +++ b/internal/cmd/local_config_trust.go @@ -47,6 +47,21 @@ func ensureLocalConfigTrusted(cmd *cobra.Command) error { } } +// commandIgnoresLocalConfig reports whether a command reads only the global and +// environment configuration, so a repository-local .hey/config.json is never +// even parsed for it. The bar poller runs from the shell's working directory, +// wherever that happens to be: a local config must neither redirect it to +// another server nor fail it (trust gate or malformed file) — the indicator +// has to stay dark rather than error. setup omarchy only edits fixed desktop +// paths and must not be blocked by a checkout's config either. +func commandIgnoresLocalConfig(cmd *cobra.Command) bool { + parts := strings.Fields(cmd.CommandPath()) + if len(parts) < 2 { + return false + } + return parts[1] == "omarchy" || (len(parts) >= 3 && parts[1] == "setup" && parts[2] == "omarchy") +} + // commandUsesRuntimeConfig reports whether a command reads the effective // server or account, which is what trusting a local config approves. upgrade // and version talk only to GitHub and the local install, so an untrusted diff --git a/internal/cmd/omarchy.go b/internal/cmd/omarchy.go new file mode 100644 index 00000000..20a5a6f9 --- /dev/null +++ b/internal/cmd/omarchy.go @@ -0,0 +1,849 @@ +package cmd + +import ( + "bytes" + "context" + _ "embed" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/basecamp/hey-cli/internal/output" +) + +// Omarchy integration: `hey setup omarchy` installs hey-cli into the desktop +// (launcher entry, menu rows, bar indicator, theme template) and `hey omarchy +// bar-status` is the command the bar indicator runs. +// +// Omarchy already ships HEY as a web app (SUPER+SHIFT+E, the mailto handler, a +// HEY.desktop). Everything here complements that under its own names and never +// edits the user's keybindings file — the binding is printed for them to paste. + +//go:embed omarchy/hey.toml.tpl +var omarchyThemeTemplate string + +const ( + omarchyAppID = "org.omarchy.hey" + omarchyDesktopName = "HEY TUI" + omarchyBarModuleID = "hey-unread" + omarchyMenuBegin = " // >>> hey-cli — managed by `hey setup omarchy`, do not edit between the markers" + omarchyMenuEnd = " // <<< hey-cli" + omarchyFocusCommand = "omarchy-launch-or-focus-tui --app-id=" + omarchyAppID + " hey tui" + omarchyBarGlyph = "" // nf-fa-envelope; verified to render in the bar's JetBrainsMono Nerd Font + // omarchy-theme-refresh re-renders every template and retints every app; a + // minute is generous. + omarchyCommandTimeout = time.Minute + // The hint spells the focus command out rather than using `{ tui = "hey tui" }`: + // the lua helper shell-quotes that into one word and launch-or-focus-tui would + // derive the app-id from it, never matching the window every other surface opens. + omarchyKeybindHint = `Add a keybinding yourself — hey never edits ~/.config/hypr/bindings.lua: + + o.bind("SUPER + SHIFT + ALT + H", "HEY TUI", "` + omarchyFocusCommand + `") + +SUPER+SHIFT+E still opens the HEY web app. To point it at the TUI instead: + + hl.unbind("SUPER + SHIFT + E") + o.bind("SUPER + SHIFT + E", "HEY", "` + omarchyFocusCommand + `") +` +) + +// omarchyEnv is everything the setup steps touch, injectable for tests. +type omarchyEnv struct { + home string + omarchyPath string + iconRoots []string // icon theme roots searched for Omarchy's HEY icon + run func(name string, args ...string) error +} + +func liveOmarchyEnv() omarchyEnv { + home, _ := os.UserHomeDir() + return omarchyEnv{ + home: home, + omarchyPath: os.Getenv("OMARCHY_PATH"), + iconRoots: []string{filepath.Join(home, ".local", "share", "icons"), "/usr/share/icons"}, + run: func(name string, args ...string) error { + if _, err := exec.LookPath(name); err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), omarchyCommandTimeout) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) //nolint:gosec // G204: fixed omarchy command names + cmd.Stdout, cmd.Stderr = io.Discard, io.Discard + return cmd.Run() + }, + } +} + +func (e omarchyEnv) detected() bool { + if e.omarchyPath != "" { + return true + } + info, err := os.Stat(filepath.Join(e.home, ".local", "state", "omarchy")) + return err == nil && info.IsDir() +} + +func (e omarchyEnv) configDir() string { return filepath.Join(e.home, ".config", "omarchy") } + +func (e omarchyEnv) desktopPath() string { + return filepath.Join(e.home, ".local", "share", "applications", omarchyDesktopName+".desktop") +} + +func (e omarchyEnv) menuPath() string { + return filepath.Join(e.configDir(), "extensions", "omarchy-menu.jsonc") +} + +func (e omarchyEnv) shellPath() string { return filepath.Join(e.configDir(), "shell.json") } + +func (e omarchyEnv) templatePath() string { + return filepath.Join(e.configDir(), "themed", "hey.toml.tpl") +} + +// defaultShellPath finds Omarchy's shipped shell.json: OMARCHY_PATH when set, +// else the per-user install, else the system one — OMARCHY_PATH is absent in +// non-login and agent environments, and the normal install is per-user. +func (e omarchyEnv) defaultShellPath() string { + roots := []string{e.omarchyPath, filepath.Join(e.home, ".local", "share", "omarchy"), "/usr/share/omarchy"} + for _, root := range roots { + if root == "" { + continue + } + path := filepath.Join(root, "config", "omarchy", "shell.json") + if _, err := os.Stat(path); err == nil { + return path + } + } + return filepath.Join("/usr/share/omarchy", "config", "omarchy", "shell.json") +} + +// iconName picks the HEY icon Omarchy installs for its web app when present, and a +// standard freedesktop mail icon otherwise. +func (e omarchyEnv) iconName() string { + for _, root := range e.iconRoots { + matches, _ := filepath.Glob(filepath.Join(root, "hicolor", "*", "apps", "hey.*")) + if len(matches) > 0 { + return "hey" + } + } + return "internet-mail" +} + +// --- Steps --- + +type omarchyStep struct { + Name string `json:"name"` + Status string `json:"status"` // installed, unchanged, removed, absent, kept, failed + Detail string `json:"detail,omitempty"` + Path string `json:"path,omitempty"` + failure error +} + +type omarchySetup struct { + env omarchyEnv +} + +func (s omarchySetup) apply() []omarchyStep { + return []omarchyStep{ + s.installDesktop(), + s.installMenu(), + s.installBar(), + s.installTemplate(), + } +} + +func (s omarchySetup) remove() []omarchyStep { + return []omarchyStep{ + s.removeDesktop(), + s.removeMenu(), + s.removeBar(), + s.removeTemplate(), + } +} + +func stepResult(name, path string, changed bool, err error, installed, unchanged string) omarchyStep { + step := omarchyStep{Name: name, Path: path} + switch { + case err != nil: + step.Status, step.Detail, step.failure = "failed", err.Error(), err + case changed: + step.Status = installed + default: + step.Status = unchanged + } + return step +} + +// Desktop entry: what `omarchy-tui-install` writes, under the app-id every other +// surface launches with so they all focus the same window. + +func omarchyDesktopEntry(icon string) string { + return fmt.Sprintf(`[Desktop Entry] +Version=1.0 +Name=%s +Comment=HEY email, contacts and calendar in the terminal +Exec=xdg-terminal-exec --app-id=%s -e hey tui +Terminal=false +Type=Application +Icon=%s +StartupNotify=true +`, omarchyDesktopName, omarchyAppID, icon) +} + +func (s omarchySetup) installDesktop() omarchyStep { + path := s.env.desktopPath() + changed, err := writeFileIfChanged(path, []byte(omarchyDesktopEntry(s.env.iconName())), 0o755) + return stepResult("desktop entry", path, changed, err, "installed", "unchanged") +} + +func (s omarchySetup) removeDesktop() omarchyStep { + path := s.env.desktopPath() + changed, err := removeFileIfPresent(path) + return stepResult("desktop entry", path, changed, err, "removed", "absent") +} + +// Menu: a marker-delimited block in the user's JSONC menu extension. The shell +// tolerates trailing commas and strips full-line // comments, so the block is +// inserted right after the opening brace with every row comma-terminated. One +// root row for now; it becomes a submenu when there is more than one thing to +// open. The guard is a PATH lookup — menu guards must never call hey itself. + +// omarchyMenuBlock is the managed rows. The member key is hey-tui rather than +// hey so a user's own hey row can coexist instead of becoming a duplicate key. +func omarchyMenuBlock() string { + row := fmt.Sprintf(` "hey-tui": {"icon":"%s","label":"HEY","action":"%s","when":"command -v hey >/dev/null"},`, + omarchyBarGlyph, omarchyFocusCommand) + return omarchyMenuBegin + "\n" + row + "\n" + omarchyMenuEnd + "\n" +} + +func (s omarchySetup) installMenu() omarchyStep { + path := s.env.menuPath() + current, err := os.ReadFile(path) //nolint:gosec // G304: fixed path under the user's config dir + if err != nil && !errors.Is(err, os.ErrNotExist) { + return stepResult("menu", path, false, err, "", "") + } + next, ok := insertMenuBlock(string(current), omarchyMenuBlock()) + if !ok { + return stepResult("menu", path, false, errors.New("could not find the top-level object to extend"), "", "") + } + changed, err := writeFileIfChanged(path, []byte(next), 0o644) + return stepResult("menu", path, changed, err, "installed", "unchanged") +} + +func (s omarchySetup) removeMenu() omarchyStep { + path := s.env.menuPath() + current, err := os.ReadFile(path) //nolint:gosec // G304: fixed path under the user's config dir + if errors.Is(err, os.ErrNotExist) { + return stepResult("menu", path, false, nil, "", "absent") + } + if err != nil { + return stepResult("menu", path, false, err, "", "") + } + next := stripMenuBlock(string(current)) + changed, err := writeFileIfChanged(path, []byte(next), 0o644) + return stepResult("menu", path, changed, err, "removed", "absent") +} + +// insertMenuBlock places block after the file's first structural `{`, replacing +// any earlier block. An empty file becomes a fresh object. +func insertMenuBlock(content, block string) (string, bool) { + content = stripMenuBlock(content) + if strings.TrimSpace(content) == "" { + return "{\n" + block + "}\n", true + } + idx := structuralBraceIndex(content) + if idx < 0 { + return "", false + } + head := content[:idx+1] + tail := strings.TrimLeft(content[idx+1:], " \t") + if !strings.HasPrefix(tail, "\n") { + tail = "\n" + tail + } + return head + "\n" + block + strings.TrimPrefix(tail, "\n"), true +} + +// structuralBraceIndex is the index of the first `{` outside JSONC comments +// and strings, or -1 — a leading doc comment showing an object-shaped example +// must not be mistaken for the menu object itself. +func structuralBraceIndex(content string) int { + // Only whitespace and comments may precede the root token; anything else + // (an array root, a bare string) means the file is not a menu object. + for i := 0; i < len(content); i++ { + switch content[i] { + case ' ', '\t', '\r', '\n': + case '{': + return i + case '/': + if i+1 >= len(content) { + return -1 + } + switch content[i+1] { + case '/': + i += 2 + for i < len(content) && content[i] != '\n' { + i++ + } + case '*': + end := strings.Index(content[i+2:], "*/") + if end < 0 { + return -1 + } + i += 2 + end + 1 + default: + return -1 + } + default: + return -1 + } + } + return -1 +} + +func stripMenuBlock(content string) string { + start := strings.Index(content, omarchyMenuBegin) + if start < 0 { + return content + } + end := strings.Index(content[start:], omarchyMenuEnd) + if end < 0 { + return content + } + after := content[start+end+len(omarchyMenuEnd):] + after = strings.TrimPrefix(after, "\n") + return content[:start] + after +} + +// Bar: an inline command module in shell.json's bar layout. The shell hot-reloads +// the file, so the indicator appears as soon as it is written. + +func omarchyBarModule() map[string]any { + return map[string]any{ + "id": omarchyBarModuleID, + "type": "command", + "exec": "hey omarchy bar-status", + "interval": 180, + "tooltip": "HEY", + "onClick": omarchyFocusCommand, + } +} + +func (s omarchySetup) installBar() omarchyStep { + path := s.env.shellPath() + shell, err := s.loadShellConfig() + if err != nil { + return stepResult("bar indicator", path, false, err, "", "") + } + layout, err := s.barLayout(shell) + if err != nil { + return stepResult("bar indicator", path, false, err, "", "") + } + module := barLayoutModule(layout, omarchyBarModuleID) + if module == nil { + right, ok := layout["right"].([]any) + if raw, present := layout["right"]; present && raw != nil && !ok { + return stepResult("bar indicator", path, false, fmt.Errorf("shell.json: bar.layout.right is %T, not a list", raw), "", "") + } + layout["right"] = append([]any{omarchyBarModule()}, right...) + changed, err := writeJSONFile(path, shell) + return stepResult("bar indicator", path, changed, err, "installed", "unchanged") + } + // An existing module is reconciled field by field, keeping its section and + // position, so a re-run after an upgrade picks up a changed exec, click + // command or interval instead of reporting a stale module unchanged. + desired := omarchyBarModule() + changed := !sameJSON(module, desired) + if changed { + clear(module) + for key, value := range desired { + module[key] = value + } + if _, err := writeJSONFile(path, shell); err != nil { + return stepResult("bar indicator", path, false, err, "", "") + } + } + return stepResult("bar indicator", path, changed, nil, "installed", "unchanged") +} + +// barLayoutModule finds our inline module map by id. String-form entries are +// not ours — setup always writes maps — so they are ignored here. +func barLayoutModule(layout map[string]any, id string) map[string]any { + for _, entries := range layout { + list, _ := entries.([]any) + for _, entry := range list { + if module, ok := entry.(map[string]any); ok && barEntryID(module) == id { + return module + } + } + } + return nil +} + +func (s omarchySetup) removeBar() omarchyStep { + path := s.env.shellPath() + shell, err := s.loadShellConfig() + if err != nil { + return stepResult("bar indicator", path, false, err, "", "") + } + bar, _ := shell["bar"].(map[string]any) + layout, _ := bar["layout"].(map[string]any) + if barLayoutModule(layout, omarchyBarModuleID) == nil { + return stepResult("bar indicator", path, false, nil, "", "absent") + } + for section, entries := range layout { + list, ok := entries.([]any) + if !ok { + continue + } + kept := make([]any, 0, len(list)) + for _, entry := range list { + // Only the map form is ours: install treats string-form entries as + // unowned, so removal must too. + if _, isMap := entry.(map[string]any); !isMap || barEntryID(entry) != omarchyBarModuleID { + kept = append(kept, entry) + } + } + layout[section] = kept + } + // Install may have seeded the layout from Omarchy's defaults just to hold our + // module. If what remains is exactly the current defaults, drop it so the user + // goes back to inheriting future default-layout changes. + if defaults, defErr := s.defaultBarLayout(); defErr == nil && sameJSON(layout, defaults) { + delete(bar, "layout") + if len(bar) == 0 { + delete(shell, "bar") + } + } + changed, err := writeJSONFile(path, shell) + return stepResult("bar indicator", path, changed, err, "removed", "absent") +} + +// loadShellConfig reads the user's shell.json. The shell ignores any config +// without `"version": 1` (shell.qml warns and falls back to the defaults), so a +// missing file starts from that marker and a version-less file is refused rather +// than edited into a config the shell would keep ignoring. +func (s omarchySetup) loadShellConfig() (map[string]any, error) { + data, err := os.ReadFile(s.env.shellPath()) + if errors.Is(err, os.ErrNotExist) { + return map[string]any{"version": 1}, nil + } + if err != nil { + return nil, err + } + shell, err := decodeJSONObject(data) + if err != nil { + return nil, fmt.Errorf("shell.json is not plain JSON: %w", err) + } + if shell == nil { + return nil, errors.New("shell.json is not a JSON object") + } + if version, ok := shell["version"].(json.Number); !ok || version.String() != "1" { + return nil, errors.New(`shell.json has no "version": 1, so the shell ignores it; add the version and re-run`) + } + return shell, nil +} + +// decodeJSONObject decodes with UseNumber so a user's opaque numeric settings — +// an integer past float64's exact range, say — survive the round trip that +// rewriting the file implies, instead of being silently rounded. +func decodeJSONObject(data []byte) (map[string]any, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + var object map[string]any + if err := decoder.Decode(&object); err != nil { + return nil, err + } + // One value and nothing after it: a file with trailing data is not plain + // JSON, and rewriting it would silently drop whatever followed. + if err := decoder.Decode(new(any)); !errors.Is(err, io.EOF) { + return nil, errors.New("trailing data after the top-level object") + } + return object, nil +} + +// sameJSON compares two values by their JSON encoding, which is what makes a +// decoded json.Number("180") and a literal 180 read as equal. +func sameJSON(a, b any) bool { + left, errA := json.Marshal(a) + right, errB := json.Marshal(b) + return errA == nil && errB == nil && bytes.Equal(left, right) +} + +// barLayout returns the user's bar layout, seeding it from Omarchy's default layout +// when the user has never customized the bar — the shell treats a missing layout +// as "use the defaults", so adding one module means spelling the rest out too. +func (s omarchySetup) barLayout(shell map[string]any) (map[string]any, error) { + // A present value of the wrong type is someone's configuration, not an + // absence: refuse to replace it. + bar, ok := shell["bar"].(map[string]any) + if raw, present := shell["bar"]; present && raw != nil && !ok { + return nil, fmt.Errorf("shell.json: bar is %T, not an object", raw) + } + if bar == nil { + bar = map[string]any{} + shell["bar"] = bar + } + layout, ok := bar["layout"].(map[string]any) + if raw, present := bar["layout"]; present && raw != nil && !ok { + return nil, fmt.Errorf("shell.json: bar.layout is %T, not an object", raw) + } + if ok { + return layout, nil + } + layout, err := s.defaultBarLayout() + if err != nil { + return nil, fmt.Errorf("no bar layout in shell.json to extend: %w", err) + } + bar["layout"] = layout + return layout, nil +} + +// defaultBarLayout reads the bar layout Omarchy ships as its default. +func (s omarchySetup) defaultBarLayout() (map[string]any, error) { + data, err := os.ReadFile(s.env.defaultShellPath()) + if err != nil { + return nil, err + } + defaults, err := decodeJSONObject(data) + if err != nil { + return nil, fmt.Errorf("default shell.json: %w", err) + } + defaultBar, _ := defaults["bar"].(map[string]any) + layout, ok := defaultBar["layout"].(map[string]any) + if !ok { + return nil, errors.New("default shell.json has no bar layout") + } + return layout, nil +} + +func barEntryID(entry any) string { + switch v := entry.(type) { + case string: + return v + case map[string]any: + id, _ := v["id"].(string) + return id + } + return "" +} + +// Theme template: lets theme authors override the overlay per theme. The TUI reads +// colors.toml directly when no hey.toml is rendered, so this step is optional. + +// omarchyTemplateMarker is the first line of every template hey ships. A +// hey.toml.tpl without it was written by someone else — the user, another +// installer — and is theirs: install keeps it and remove leaves it alone. +const omarchyTemplateMarker = "# hey-cli accent overlay" + +// templateIsOurs reports whether the template at path carries hey's marker. A +// file that exists but cannot be read is an error, not an absence: the +// ownership guard must never be bypassed by an unreadable foreign file. +func templateIsOurs(path string) (ours, exists bool, err error) { + current, err := os.ReadFile(path) //nolint:gosec // G304: fixed path under the user's config dir + if errors.Is(err, os.ErrNotExist) { + // A dangling symlink reads as missing but is still the user's link; + // ownership cannot be established, so it is foreign, not absent. + if info, lerr := os.Lstat(path); lerr == nil && info.Mode()&os.ModeSymlink != 0 { + return false, true, nil + } + return false, false, nil + } + if err != nil { + return false, true, err + } + return strings.HasPrefix(string(current), omarchyTemplateMarker), true, nil +} + +func (s omarchySetup) installTemplate() omarchyStep { + path := s.env.templatePath() + ours, exists, err := templateIsOurs(path) + if err != nil { + return stepResult("theme template", path, false, err, "", "") + } + if exists && !ours { + return omarchyStep{Name: "theme template", Path: path, Status: "kept", + Detail: "existing template not written by hey; left as is"} + } + changed, err := writeFileIfChanged(path, []byte(omarchyThemeTemplate), 0o644) + if err == nil && changed { + if refreshErr := s.env.run("omarchy-theme-refresh"); refreshErr != nil { + return omarchyStep{Name: "theme template", Path: path, Status: "installed", + Detail: "rendered on the next theme switch (omarchy-theme-refresh unavailable)"} + } + } + return stepResult("theme template", path, changed, err, "installed", "unchanged") +} + +func (s omarchySetup) removeTemplate() omarchyStep { + path := s.env.templatePath() + ours, exists, err := templateIsOurs(path) + if err != nil { + return stepResult("theme template", path, false, err, "", "") + } + if exists && !ours { + return omarchyStep{Name: "theme template", Path: path, Status: "kept", + Detail: "existing template not written by hey; left as is"} + } + changed, err := removeFileIfPresent(path) + if err == nil && changed { + if refreshErr := s.env.run("omarchy-theme-refresh"); refreshErr != nil { + return omarchyStep{Name: "theme template", Path: path, Status: "removed", + Detail: "rendered hey.toml stays until the next theme switch (omarchy-theme-refresh unavailable)"} + } + } + return stepResult("theme template", path, changed, err, "removed", "absent") +} + +// --- File helpers --- + +// writeFileIfChanged writes via a temp file and rename, the way Omarchy's own +// config mutators do, so an interrupted write can never leave a half-truncated +// shell.json or menu behind. A symlink (a dotfiles repo, say) is followed so +// the target is replaced rather than the link, and an existing file keeps its +// mode: perm applies to files created from nothing. +func writeFileIfChanged(path string, data []byte, perm os.FileMode) (bool, error) { + if current, err := os.ReadFile(path); err == nil && bytes.Equal(current, data) { //nolint:gosec // G304: caller-controlled config path + return false, nil + } + if target, err := filepath.EvalSymlinks(path); err == nil { + path = target + } else if info, lerr := os.Lstat(path); lerr == nil && info.Mode()&os.ModeSymlink != 0 { + // A dangling link cannot be followed, and renaming over it would + // silently turn the user's link into a plain file. + return false, fmt.Errorf("%s is a symlink whose target is missing: %w", path, err) + } + if info, err := os.Stat(path); err == nil { + perm = info.Mode().Perm() + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return false, err + } + tmp, err := os.CreateTemp(filepath.Dir(path), filepath.Base(path)+".tmp-*") + if err != nil { + return false, err + } + defer os.Remove(tmp.Name()) //nolint:errcheck // gone already after a successful rename + writeErr := tmp.Chmod(perm) + if writeErr == nil { + _, writeErr = tmp.Write(data) + } + if closeErr := tmp.Close(); writeErr == nil { + writeErr = closeErr + } + if writeErr != nil { + return false, writeErr + } + return true, os.Rename(tmp.Name(), path) +} + +func removeFileIfPresent(path string) (bool, error) { + err := os.Remove(path) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return err == nil, err +} + +func writeJSONFile(path string, value any) (bool, error) { + var buf bytes.Buffer + encoder := json.NewEncoder(&buf) + encoder.SetIndent("", " ") + encoder.SetEscapeHTML(false) + if err := encoder.Encode(value); err != nil { + return false, err + } + return writeFileIfChanged(path, buf.Bytes(), 0o644) +} + +// --- hey setup omarchy --- + +type setupOmarchyCommand struct { + cmd *cobra.Command + remove bool + env omarchyEnv +} + +func newSetupOmarchyCommand() *setupOmarchyCommand { + setupOmarchyCommand := &setupOmarchyCommand{env: liveOmarchyEnv()} + setupOmarchyCommand.cmd = &cobra.Command{ + Use: "omarchy", + Args: cobra.NoArgs, + Short: "Install hey into the Omarchy desktop", + Long: `Install hey into the Omarchy desktop: a launcher entry, rows in the SUPER+SPACE +menu, an unread indicator on the bar, and a theme template so themes can tune the +TUI's accent colors. Every piece is idempotent and --remove takes them all out again. + +Theming needs none of this: on Omarchy the TUI already follows the active theme.`, + Example: ` hey setup omarchy + hey setup omarchy --remove`, + Annotations: map[string]string{ + "agent_notes": "Only meaningful on Omarchy Linux. Writes to ~/.config/omarchy and ~/.local/share/applications; never edits Hyprland keybindings.", + }, + RunE: setupOmarchyCommand.run, + } + setupOmarchyCommand.cmd.Flags().BoolVar(&setupOmarchyCommand.remove, "remove", false, "Remove everything hey setup omarchy installed") + return setupOmarchyCommand +} + +func (c *setupOmarchyCommand) run(cmd *cobra.Command, args []string) error { + // List-only formats cannot render the step report; refuse them before any + // file is touched rather than failing after a successful install. + if format := writer.EffectiveFormat(); format == output.FormatIDs || format == output.FormatCount { + return output.ErrUsageHint("hey setup omarchy reports steps, not a list", "use --json for machine-readable output") + } + // Every path is under the home directory; without one they would resolve + // relative to the working directory, and --remove would delete from there. + if !filepath.IsAbs(c.env.home) { + return &output.Error{Code: "setup_failed", Message: "cannot resolve an absolute home directory", Hint: "set HOME to an absolute path and run hey setup omarchy again"} + } + // Detection gates installation only: removal operates on fixed user paths + // and must still work after Omarchy itself is gone. + if !c.remove && !c.env.detected() { + return output.ErrUsageHint("Omarchy not detected", "hey setup omarchy needs ~/.local/state/omarchy or OMARCHY_PATH") + } + + setup := omarchySetup{env: c.env} + var steps []omarchyStep + if c.remove { + steps = setup.remove() + } else { + steps = setup.apply() + } + + var failures []string + for _, step := range steps { + if step.failure != nil { + failures = append(failures, step.Name+": "+step.Detail) + } + } + + if writer.IsStyled() { + w := cmd.OutOrStdout() + for _, step := range steps { + detail := step.Path + if step.Detail != "" { + detail = step.Detail + } + fmt.Fprintf(w, "%-16s %-10s %s\n", step.Name, step.Status, detail) + } + if !c.remove { + fmt.Fprintln(w) + fmt.Fprint(w, omarchyKeybindHint) + } + if len(failures) > 0 { + return fmt.Errorf("%d step(s) failed", len(failures)) + } + return nil + } + + summary := "Omarchy integration installed" + if c.remove { + summary = "Omarchy integration removed" + } + data := map[string]any{"steps": steps} + if !c.remove { + data["keybind_hint"] = omarchyKeybindHint + } + if len(failures) > 0 { + // An operational failure, not a usage error: some steps already changed + // files, and they ride along in the error meta so a scripting caller can + // see which pieces landed and which did not. + return &output.Error{ + Code: "setup_failed", + Message: strings.Join(failures, "; "), + Hint: "fix the paths above and run hey setup omarchy again", + Meta: map[string]any{"steps": steps}, + } + } + return writeOK(data, output.WithSummary(summary)) +} + +// --- hey omarchy bar-status --- + +type omarchyCommand struct { + cmd *cobra.Command +} + +func newOmarchyCommand() *omarchyCommand { + omarchyCommand := &omarchyCommand{} + omarchyCommand.cmd = &cobra.Command{ + Use: "omarchy", + Short: "Commands the Omarchy desktop integration runs", + Hidden: true, + } + omarchyCommand.cmd.AddCommand(newOmarchyBarStatusCommand().cmd) + return omarchyCommand +} + +type omarchyBarStatusCommand struct { + cmd *cobra.Command +} + +func newOmarchyBarStatusCommand() *omarchyBarStatusCommand { + omarchyBarStatusCommand := &omarchyBarStatusCommand{} + omarchyBarStatusCommand.cmd = &cobra.Command{ + Use: "bar-status", + Short: "Print the bar indicator for unread Imbox mail", + Long: `Print a Waybar-style JSON module when the Imbox has unread mail and nothing when +it does not. Never fails: when hey is logged out or offline the indicator simply +stays dark, because a bar is no place for an error message.`, + Args: cobra.NoArgs, + RunE: omarchyBarStatusCommand.run, + } + return omarchyBarStatusCommand +} + +// configDegraded is set by the root pre-run when the global configuration could +// not be loaded and a config-ignoring command went on with the defaults. The +// poller then stays dark: lighting the indicator against a guessed server would +// be a lie, and an error is not an option either. +var configDegraded bool + +func (c *omarchyBarStatusCommand) run(cmd *cobra.Command, args []string) error { + if configDegraded || !authMgr.IsAuthenticated() { + return nil + } + // The omarchy command is exempt from pre-run account scoping so a failed + // selection (offline, account gone) can never surface as an error here; + // the configured account still applies when it can be selected. + if err := selectConfiguredAccount(cmd.Context()); err != nil { + return nil //nolint:nilerr // a bar is no place for an error message + } + if !imboxHasUnread(cmd.Context()) { + return nil + } + _, err := fmt.Fprintln(cmd.OutOrStdout(), omarchyBarModuleJSON()) + return err +} + +// omarchyBarModuleJSON is the Waybar-style module the bar shows when mail is +// unread. The "active" class is what the shell's command widget highlights. +func omarchyBarModuleJSON() string { + module, _ := json.Marshal(map[string]string{ // fixed strings cannot fail to marshal + "text": omarchyBarGlyph, + "tooltip": "Unread in Imbox", + "class": "active", + }) + return string(module) +} + +// imboxHasUnread reports unread Imbox mail. Unknown (offline, server error) +// counts as clear: the indicator stays dark rather than lying either way loudly. +func imboxHasUnread(ctx context.Context) bool { + resp, err := sdk.Boxes().GetImbox(ctx, nil) + if err != nil || resp == nil { + return false + } + for _, posting := range resp.Postings { + if !posting.Seen { + return true + } + } + return false +} diff --git a/internal/cmd/omarchy/hey.toml.tpl b/internal/cmd/omarchy/hey.toml.tpl new file mode 100644 index 00000000..7409a4a2 --- /dev/null +++ b/internal/cmd/omarchy/hey.toml.tpl @@ -0,0 +1,17 @@ +# hey-cli accent overlay, rendered by omarchy-theme-set from colors.toml. +# Managed by `hey setup omarchy` — edits here are overwritten on the next run. +# Override any key in your theme's own hey.toml; anything missing keeps the ANSI default. +mode = "{{ mode }}" +accent = "{{ accent }}" +selection = "{{ selection }}" +muted = "{{ muted }}" +foreground = "{{ foreground }}" +error = "{{ red }}" + +# Reference colors the accent readability gate compares against. A rendered +# hey.toml is the only theme file the TUI reads, so it has to carry them; +# an unrendered {{ key }} is simply ignored. +bright_foreground = "{{ bright_foreground }}" +background = "{{ background }}" +blue = "{{ blue }}" +bright_blue = "{{ bright_blue }}" diff --git a/internal/cmd/omarchy_test.go b/internal/cmd/omarchy_test.go new file mode 100644 index 00000000..8c270949 --- /dev/null +++ b/internal/cmd/omarchy_test.go @@ -0,0 +1,919 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/basecamp/hey-cli/internal/config" + "github.com/basecamp/hey-cli/internal/output" +) + +const defaultShellJSON = `{ + "version": 1, + "bar": { + "position": "top", + "layout": { + "left": [{"id": "omarchy.menu"}], + "center": [{"id": "omarchy.clock"}], + "right": [{"id": "omarchy.tray"}, {"id": "omarchy.power"}] + } + } +} +` + +// testOmarchyEnv fakes an Omarchy install: a home dir, an OMARCHY_PATH with the +// default shell.json, and a recorder for the commands setup would run. +func testOmarchyEnv(t *testing.T) (omarchyEnv, *[]string) { + t.Helper() + home := t.TempDir() + omarchyPath := t.TempDir() + if err := os.MkdirAll(filepath.Join(omarchyPath, "config", "omarchy"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(omarchyPath, "config", "omarchy", "shell.json"), []byte(defaultShellJSON), 0o644); err != nil { + t.Fatal(err) + } + var ran []string + env := omarchyEnv{ + home: home, + omarchyPath: omarchyPath, + run: func(name string, args ...string) error { + ran = append(ran, strings.Join(append([]string{name}, args...), " ")) + return nil + }, + } + return env, &ran +} + +func statuses(steps []omarchyStep) map[string]string { + out := make(map[string]string, len(steps)) + for _, step := range steps { + out[step.Name] = step.Status + } + return out +} + +func readText(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(data) +} + +func TestOmarchySetupInstallsEverythingOnce(t *testing.T) { + env, ran := testOmarchyEnv(t) + setup := omarchySetup{env: env} + + first := statuses(setup.apply()) + for _, name := range []string{"desktop entry", "menu", "bar indicator", "theme template"} { + if first[name] != "installed" { + t.Errorf("%s: first run = %q, want installed", name, first[name]) + } + } + + desktop := readText(t, env.desktopPath()) + if !strings.Contains(desktop, "Exec=xdg-terminal-exec --app-id=org.omarchy.hey -e hey tui") { + t.Errorf("desktop entry should launch under the shared app id:\n%s", desktop) + } + if !strings.Contains(desktop, "Icon=internet-mail") { + t.Errorf("without a hey icon installed the entry should fall back:\n%s", desktop) + } + + menu := readText(t, env.menuPath()) + if !strings.Contains(menu, `"hey-tui"`) || !strings.HasPrefix(menu, "{\n"+omarchyMenuBegin) { + t.Errorf("menu block not written:\n%s", menu) + } + + var shell map[string]any + if err := json.Unmarshal([]byte(readText(t, env.shellPath())), &shell); err != nil { + t.Fatal(err) + } + right := shell["bar"].(map[string]any)["layout"].(map[string]any)["right"].([]any) + if barEntryID(right[0]) != "hey-unread" || barEntryID(right[1]) != "omarchy.tray" { + t.Errorf("bar module should lead the right section seeded from defaults: %v", right) + } + if module := right[0].(map[string]any); module["exec"] != "hey omarchy bar-status" || module["type"] != "command" { + t.Errorf("bar module malformed: %v", module) + } + + if readText(t, env.templatePath()) != omarchyThemeTemplate { + t.Error("theme template not written") + } + if len(*ran) != 1 || (*ran)[0] != "omarchy-theme-refresh" { + t.Errorf("template install should refresh the theme once, ran %v", *ran) + } + + second := statuses(setup.apply()) + for name, status := range second { + if status != "unchanged" { + t.Errorf("%s: second run = %q, want unchanged", name, status) + } + } + if len(*ran) != 1 { + t.Errorf("an unchanged template must not refresh again, ran %v", *ran) + } +} + +func TestOmarchySetupRemoveReversesEveryPiece(t *testing.T) { + env, _ := testOmarchyEnv(t) + setup := omarchySetup{env: env} + + menuBefore := "{\n // my rows\n \"notes\": {\"icon\":\"\",\"label\":\"Notes\",\"action\":\"omarchy-launch-editor ~/notes\"},\n}\n" + if err := os.MkdirAll(filepath.Dir(env.menuPath()), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(env.menuPath(), []byte(menuBefore), 0o644); err != nil { + t.Fatal(err) + } + setup.apply() + + if menu := readText(t, env.menuPath()); !strings.Contains(menu, `"notes"`) || !strings.Contains(menu, `"hey-tui"`) { + t.Errorf("install should keep the user's rows alongside ours:\n%s", menu) + } + + removed := statuses(setup.remove()) + for name, status := range removed { + if status != "removed" { + t.Errorf("%s: remove = %q, want removed", name, status) + } + } + if _, err := os.Stat(env.desktopPath()); !os.IsNotExist(err) { + t.Error("desktop entry still present") + } + if _, err := os.Stat(env.templatePath()); !os.IsNotExist(err) { + t.Error("theme template still present") + } + if menu := readText(t, env.menuPath()); menu != menuBefore { + t.Errorf("menu should be restored byte for byte:\n%s", menu) + } + if shell := readText(t, env.shellPath()); strings.Contains(shell, "hey-unread") { + t.Errorf("bar module still present:\n%s", shell) + } + + again := statuses(setup.remove()) + for name, status := range again { + if status != "absent" { + t.Errorf("%s: second remove = %q, want absent", name, status) + } + } +} + +func TestOmarchySetupKeepsExistingBarLayout(t *testing.T) { + env, _ := testOmarchyEnv(t) + if err := os.MkdirAll(env.configDir(), 0o755); err != nil { + t.Fatal(err) + } + custom := `{"version":1,"bar":{"layout":{"left":[{"id":"omarchy.menu"}],"center":[],"right":["omarchy.audio"]}},"idle":{"lock":600}}` + if err := os.WriteFile(env.shellPath(), []byte(custom), 0o644); err != nil { + t.Fatal(err) + } + + omarchySetup{env: env}.apply() + + var shell map[string]any + if err := json.Unmarshal([]byte(readText(t, env.shellPath())), &shell); err != nil { + t.Fatal(err) + } + if shell["idle"].(map[string]any)["lock"] != float64(600) { + t.Error("unrelated settings must survive") + } + right := shell["bar"].(map[string]any)["layout"].(map[string]any)["right"].([]any) + if len(right) != 2 || barEntryID(right[1]) != "omarchy.audio" { + t.Errorf("string-form entries must be kept: %v", right) + } +} + +func TestOmarchySetupRejectsNonJSONShellConfig(t *testing.T) { + env, _ := testOmarchyEnv(t) + if err := os.MkdirAll(env.configDir(), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(env.shellPath(), []byte("{ // comment\n}"), 0o644); err != nil { + t.Fatal(err) + } + steps := statuses(omarchySetup{env: env}.apply()) + if steps["bar indicator"] != "failed" { + t.Errorf("a shell.json we cannot round-trip must fail rather than be rewritten, got %q", steps["bar indicator"]) + } + if steps["menu"] != "installed" { + t.Error("one failing step must not stop the others") + } +} + +func TestOmarchySetupRejectsMalformedShellConfig(t *testing.T) { + env, _ := testOmarchyEnv(t) + if err := os.MkdirAll(env.configDir(), 0o755); err != nil { + t.Fatal(err) + } + + // Valid JSON, not an object: must fail, not panic. + if err := os.WriteFile(env.shellPath(), []byte("null"), 0o644); err != nil { + t.Fatal(err) + } + if steps := statuses(omarchySetup{env: env}.apply()); steps["bar indicator"] != "failed" { + t.Errorf("a null shell.json must fail the bar step, got %q", steps["bar indicator"]) + } + + // A version-less object is ignored by the shell; refuse to edit it. + if err := os.WriteFile(env.shellPath(), []byte(`{"bar":{}}`), 0o644); err != nil { + t.Fatal(err) + } + steps := omarchySetup{env: env}.apply() + for _, step := range steps { + if step.Name == "bar indicator" { + if step.Status != "failed" || !strings.Contains(step.Detail, "version") { + t.Errorf("a version-less shell.json must fail with a version hint, got %q %q", step.Status, step.Detail) + } + } + } +} + +func TestOmarchySetupRefusesWrongTypedBarFields(t *testing.T) { + for _, shell := range []string{ + `{"version":1,"bar":"top"}`, + `{"version":1,"bar":{"layout":[]}}`, + `{"version":1,"bar":{"layout":{"right":"omarchy.tray"}}}`, + `{"version":1,"bar":{}} {"trailing":true}`, + } { + env, _ := testOmarchyEnv(t) + if err := os.MkdirAll(env.configDir(), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(env.shellPath(), []byte(shell), 0o644); err != nil { + t.Fatal(err) + } + if steps := statuses(omarchySetup{env: env}.apply()); steps["bar indicator"] != "failed" { + t.Errorf("%s: a wrong-typed value must fail, not be replaced, got %q", shell, steps["bar indicator"]) + } + if readText(t, env.shellPath()) != shell { + t.Errorf("%s: the file must be left untouched", shell) + } + } +} + +func TestBarStatusIgnoresRepositoryLocalConfig(t *testing.T) { + repo := t.TempDir() + if err := os.MkdirAll(filepath.Join(repo, ".hey"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".hey", "config.json"), []byte(`{"base_url":"https://untrusted.example.com"}`), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(repo) + server := imboxServer(t, `[]`) + defer server.Close() + + // An untrusted checkout must neither fail the poller at the trust gate + // nor point it at the checkout's server. + out, err := runBarStatus(t, server.URL, true) + if err != nil || out != "" { + t.Errorf("the poller must stay dark and silent from an untrusted checkout, got %q, %v", out, err) + } + if cfg.UntrustedLocalConfig() != nil || cfg.SourceOf("base_url") != config.SourceFlag { + t.Errorf("the poller must load global configuration only, got base_url from %v", cfg.SourceOf("base_url")) + } + + root := newRootCmd() + command, _, _ := root.Find([]string{"setup", "omarchy"}) + if !commandIgnoresLocalConfig(command) { + t.Error("setup omarchy edits fixed desktop paths only and must ignore a checkout's config too") + } + if boxes, _, _ := root.Find([]string{"boxes"}); commandIgnoresLocalConfig(boxes) { + t.Error("ordinary commands keep honouring repository-local config") + } +} + +func TestSetupOmarchyRemoveIgnoresMalformedLocalConfig(t *testing.T) { + repo := t.TempDir() + if err := os.MkdirAll(filepath.Join(repo, ".hey"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".hey", "config.json"), []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(repo) + t.Setenv("HEY_NO_KEYRING", "1") + t.Setenv("HEY_BASE_URL", "") + t.Setenv("OMARCHY_PATH", "") + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_STATE_HOME", t.TempDir()) + + root := newRootCmd() + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + root.SetArgs([]string{"setup", "omarchy", "--remove", "--json"}) + if err := root.Execute(); err != nil { + t.Fatalf("a malformed checkout config must not block a filesystem-only command, got %v", err) + } +} + +func TestSetupOmarchyFailsWithoutAHomeDirectory(t *testing.T) { + t.Setenv("HEY_NO_KEYRING", "1") + t.Setenv("HEY_BASE_URL", "") + t.Setenv("OMARCHY_PATH", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_STATE_HOME", t.TempDir()) + t.Setenv("HOME", "") + cwd := t.TempDir() + t.Chdir(cwd) + + root := newRootCmd() + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + root.SetArgs([]string{"setup", "omarchy", "--remove", "--json"}) + if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "home directory") { + t.Fatalf("without a home directory setup must refuse to touch anything, got %v", err) + } + if _, err := os.Stat(filepath.Join(cwd, ".config")); !os.IsNotExist(err) { + t.Error("nothing may be created relative to the working directory") + } +} + +func TestOmarchySetupSeedsVersionAndRestoresDefaultsOnRemove(t *testing.T) { + env, _ := testOmarchyEnv(t) + setup := omarchySetup{env: env} + + setup.apply() + var shell map[string]any + if err := json.Unmarshal([]byte(readText(t, env.shellPath())), &shell); err != nil { + t.Fatal(err) + } + if shell["version"] != float64(1) { + t.Errorf(`a freshly created shell.json needs "version": 1 or the shell ignores it: %v`, shell) + } + + removed := setup.remove() + if steps := statuses(removed); steps["bar indicator"] != "removed" { + for _, step := range removed { + t.Logf("%s: %s %s", step.Name, step.Status, step.Detail) + } + t.Fatalf("bar step = %q, want removed", steps["bar indicator"]) + } + var after map[string]any // a fresh map: Unmarshal into a non-nil map merges keys + if err := json.Unmarshal([]byte(readText(t, env.shellPath())), &after); err != nil { + t.Fatal(err) + } + if _, has := after["bar"]; has { + t.Errorf("removing from a defaults-seeded layout should restore inheriting the defaults: %v", after) + } +} + +func TestOmarchySetupReconcilesStaleBarModule(t *testing.T) { + env, _ := testOmarchyEnv(t) + if err := os.MkdirAll(env.configDir(), 0o755); err != nil { + t.Fatal(err) + } + stale := `{"version":1,"bar":{"layout":{"left":[],"center":[],"right":[ + {"id":"hey-unread","type":"command","exec":"hey omarchy bar-status --old-flag","interval":60, + "tooltip":"HEY","onClick":"omarchy-launch-or-focus-tui --app-id=org.omarchy.hey hey"}, + {"id":"omarchy.tray"}]}}}` + if err := os.WriteFile(env.shellPath(), []byte(stale), 0o644); err != nil { + t.Fatal(err) + } + + if steps := statuses(omarchySetup{env: env}.apply()); steps["bar indicator"] != "installed" { + t.Errorf("a stale module should be rewritten, got %q", steps["bar indicator"]) + } + var shell map[string]any + if err := json.Unmarshal([]byte(readText(t, env.shellPath())), &shell); err != nil { + t.Fatal(err) + } + layout := shell["bar"].(map[string]any)["layout"].(map[string]any) + module := barLayoutModule(layout, omarchyBarModuleID) + if module["exec"] != "hey omarchy bar-status" || module["onClick"] != omarchyFocusCommand || module["interval"] != float64(180) { + t.Errorf("stale fields should be reconciled: %v", module) + } + if right := layout["right"].([]any); len(right) != 2 || barEntryID(right[1]) != "omarchy.tray" { + t.Errorf("module position and neighbours must be kept: %v", right) + } + if neighbour := barLayoutModule(layout, "omarchy.tray"); neighbour == nil || neighbour["id"] != "omarchy.tray" { + t.Errorf("other inline modules must be findable too: %v", neighbour) + } + if again := statuses(omarchySetup{env: env}.apply()); again["bar indicator"] != "unchanged" { + t.Errorf("reconciled module must be stable, got %q", again["bar indicator"]) + } + // A string-form entry that happens to share our id is not ours: neither + // found by the module lookup nor deleted on removal. + layout["left"] = append(layout["left"].([]any), "hey-unread") + if barLayoutModule(layout, omarchyBarModuleID)["type"] != "command" { + t.Error("the string-form entry must not shadow the managed map") + } + withString, err := json.Marshal(shell) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(env.shellPath(), withString, 0o644); err != nil { + t.Fatal(err) + } + setup := omarchySetup{env: env} + if steps := statuses(setup.remove()); steps["bar indicator"] != "removed" { + t.Errorf("remove should still find the managed map, got %q", steps["bar indicator"]) + } + if err := json.Unmarshal([]byte(readText(t, env.shellPath())), &shell); err != nil { + t.Fatal(err) + } + left := shell["bar"].(map[string]any)["layout"].(map[string]any)["left"].([]any) + if len(left) != 1 || left[0] != "hey-unread" { + t.Errorf("a string-form entry sharing our id is unowned and must survive removal: %v", left) + } +} + +func TestOmarchyDefaultShellPathFallsBackToUserTree(t *testing.T) { + env, _ := testOmarchyEnv(t) + env.omarchyPath = "" // no OMARCHY_PATH, as in a non-login or agent shell + userTree := filepath.Join(env.home, ".local", "share", "omarchy", "config", "omarchy") + if err := os.MkdirAll(userTree, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(userTree, "shell.json"), []byte(defaultShellJSON), 0o644); err != nil { + t.Fatal(err) + } + + if steps := statuses(omarchySetup{env: env}.apply()); steps["bar indicator"] != "installed" { + t.Errorf("the per-user omarchy tree should seed the layout, got %q", steps["bar indicator"]) + } +} + +func TestOmarchyRemoveTemplateReportsDeferredRender(t *testing.T) { + env, _ := testOmarchyEnv(t) + env.run = func(name string, args ...string) error { return errors.New("not on PATH") } + setup := omarchySetup{env: env} + + setup.apply() + var removed omarchyStep + for _, step := range setup.remove() { + if step.Name == "theme template" { + removed = step + } + } + if removed.Status != "removed" || !strings.Contains(removed.Detail, "next theme switch") { + t.Errorf("a failed refresh on removal must be reported, got %q %q", removed.Status, removed.Detail) + } +} + +func TestSetupOmarchyRemoveWorksWithoutOmarchy(t *testing.T) { + t.Setenv("HEY_NO_KEYRING", "1") + t.Setenv("HEY_BASE_URL", "") + t.Setenv("OMARCHY_PATH", "") + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_STATE_HOME", t.TempDir()) + + root := newRootCmd() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs([]string{"setup", "omarchy", "--remove", "--json"}) + if err := root.Execute(); err != nil { + t.Fatalf("removal must work after Omarchy itself is gone, got %v", err) + } + if !strings.Contains(buf.String(), "absent") { + t.Errorf("steps should report absent: %s", buf.String()) + } +} + +func TestSetupOmarchyRejectsPositionalArgs(t *testing.T) { + t.Setenv("HEY_NO_KEYRING", "1") + t.Setenv("OMARCHY_PATH", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + root := newRootCmd() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs([]string{"setup", "omarchy", "remove"}) + if err := root.Execute(); err == nil { + t.Fatal("a positional argument must be rejected, not silently ignored") + } +} + +func TestSetupOmarchyFailureCarriesStepsInMeta(t *testing.T) { + t.Setenv("HEY_NO_KEYRING", "1") + t.Setenv("HEY_BASE_URL", "") + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_STATE_HOME", t.TempDir()) + omarchyPath := t.TempDir() + t.Setenv("OMARCHY_PATH", omarchyPath) + shellDir := filepath.Join(home, ".config", "omarchy") + if err := os.MkdirAll(shellDir, 0o755); err != nil { + t.Fatal(err) + } + // A version-less shell.json fails the bar step while the others succeed. + if err := os.WriteFile(filepath.Join(shellDir, "shell.json"), []byte(`{"bar":{}}`), 0o644); err != nil { + t.Fatal(err) + } + + root := newRootCmd() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs([]string{"setup", "omarchy", "--json"}) + err := root.Execute() + if err == nil { + t.Fatal("a failing step must fail the command") + } + typed := output.AsError(err) + if typed.Meta == nil || typed.Meta["steps"] == nil { + t.Errorf("a partial failure must carry the per-step results for scripting callers, got %+v", typed) + } + if typed.Code != "setup_failed" { + t.Errorf("an operational failure must not be reported as a usage error, got code %q", typed.Code) + } +} + +func TestOmarchySetupFailsOnUnreadableTemplate(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root can read anything") + } + env, _ := testOmarchyEnv(t) + if err := os.MkdirAll(filepath.Dir(env.templatePath()), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(env.templatePath(), []byte("# someone else's template\n"), 0o000); err != nil { + t.Fatal(err) + } + + setup := omarchySetup{env: env} + if steps := statuses(setup.apply()); steps["theme template"] != "failed" { + t.Errorf("an unreadable template must fail the step, not be treated as absent, got %q", steps["theme template"]) + } + if steps := statuses(setup.remove()); steps["theme template"] != "failed" { + t.Errorf("remove must not delete a template whose ownership it cannot read, got %q", steps["theme template"]) + } + if _, err := os.Stat(env.templatePath()); err != nil { + t.Error("the unreadable template must survive") + } +} + +func TestWriteFileIfChangedFollowsSymlinksAndKeepsMode(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "dotfiles", "shell.json") + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(target, []byte("old"), 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(dir, "shell.json") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + + if changed, err := writeFileIfChanged(link, []byte("new"), 0o644); err != nil || !changed { + t.Fatalf("write through the link: changed=%v err=%v", changed, err) + } + if info, err := os.Lstat(link); err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Error("the symlink must survive the write") + } + if data, _ := os.ReadFile(target); string(data) != "new" { + t.Errorf("the link target should hold the new content, got %q", data) + } + if info, _ := os.Stat(target); info.Mode().Perm() != 0o600 { + t.Errorf("an existing file keeps its mode, got %v", info.Mode().Perm()) + } + + fresh := filepath.Join(dir, "fresh.json") + if _, err := writeFileIfChanged(fresh, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if info, _ := os.Stat(fresh); info.Mode().Perm() != 0o644 { + t.Errorf("a new file takes the requested mode, got %v", info.Mode().Perm()) + } + + dangling := filepath.Join(dir, "dangling.json") + if err := os.Symlink(filepath.Join(dir, "gone", "menu.jsonc"), dangling); err != nil { + t.Fatal(err) + } + if _, err := writeFileIfChanged(dangling, []byte("x"), 0o644); err == nil { + t.Error("a dangling symlink must be refused, not replaced by a plain file") + } + if info, err := os.Lstat(dangling); err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Error("the dangling symlink must survive the refused write") + } +} + +func TestSetupOmarchyRefusesListFormatsBeforeTouchingFiles(t *testing.T) { + t.Setenv("HEY_NO_KEYRING", "1") + t.Setenv("HEY_BASE_URL", "") + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("OMARCHY_PATH", t.TempDir()) + + for _, flag := range []string{"--ids-only", "--count"} { + root := newRootCmd() + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + root.SetArgs([]string{"setup", "omarchy", flag}) + if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "not a list") { + t.Errorf("%s must be refused up front, got %v", flag, err) + } + if _, err := os.Stat(filepath.Join(home, ".local", "share", "applications", "HEY TUI.desktop")); !os.IsNotExist(err) { + t.Errorf("%s must be refused before any file is written", flag) + } + } +} + +func TestSetupOmarchyIsNotBlockedByAnUntrustedLocalConfig(t *testing.T) { + repo := t.TempDir() + if err := os.MkdirAll(filepath.Join(repo, ".hey"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, ".hey", "config.json"), []byte(`{"base_url":"https://untrusted.example.com"}`), 0o644); err != nil { + t.Fatal(err) + } + t.Chdir(repo) + t.Setenv("HEY_NO_KEYRING", "1") + t.Setenv("HEY_BASE_URL", "") + t.Setenv("OMARCHY_PATH", "") + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_STATE_HOME", t.TempDir()) + + root := newRootCmd() + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + root.SetArgs([]string{"setup", "omarchy", "--remove", "--json"}) + if err := root.Execute(); err != nil { + t.Fatalf("an untrusted checkout config must not block setup omarchy in a non-TTY, got %v", err) + } + if parent, _, _ := root.Find([]string{"setup"}); !commandUsesRuntimeConfig(parent) || commandIgnoresLocalConfig(parent) { + t.Error("plain hey setup signs in and still uses runtime config") + } +} + +func TestOmarchySetupPreservesLargeIntegersInShellConfig(t *testing.T) { + env, _ := testOmarchyEnv(t) + if err := os.MkdirAll(env.configDir(), 0o755); err != nil { + t.Fatal(err) + } + // 2^53+1 is not representable as a float64; a naive round trip would round it. + shell := `{"version":1,"bar":{"layout":{"left":[],"center":[],"right":[]}},"custom":{"token":9007199254740993}}` + if err := os.WriteFile(env.shellPath(), []byte(shell), 0o644); err != nil { + t.Fatal(err) + } + + if steps := statuses(omarchySetup{env: env}.apply()); steps["bar indicator"] != "installed" { + t.Fatalf("bar step = %q", steps["bar indicator"]) + } + if out := readText(t, env.shellPath()); !strings.Contains(out, "9007199254740993") { + t.Errorf("an unrelated large integer must survive the rewrite exactly:\n%s", out) + } +} + +func TestSetupOmarchyRejectsARelativeHome(t *testing.T) { + t.Setenv("HEY_NO_KEYRING", "1") + t.Setenv("HEY_BASE_URL", "") + t.Setenv("OMARCHY_PATH", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("XDG_STATE_HOME", t.TempDir()) + t.Setenv("HOME", ".") + cwd := t.TempDir() + t.Chdir(cwd) + + root := newRootCmd() + root.SetOut(&bytes.Buffer{}) + root.SetErr(&bytes.Buffer{}) + root.SetArgs([]string{"setup", "omarchy", "--remove", "--json"}) + if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "home directory") { + t.Fatalf("a relative HOME must be refused, got %v", err) + } + if _, err := os.Stat(filepath.Join(cwd, ".config")); !os.IsNotExist(err) { + t.Error("nothing may be touched relative to the working directory") + } +} + +func TestOmarchySetupKeepsDanglingTemplateSymlink(t *testing.T) { + env, _ := testOmarchyEnv(t) + if err := os.MkdirAll(filepath.Dir(env.templatePath()), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(env.home, "dotfiles", "gone", "hey.toml.tpl"), env.templatePath()); err != nil { + t.Fatal(err) + } + + setup := omarchySetup{env: env} + if steps := statuses(setup.apply()); steps["theme template"] != "kept" { + t.Errorf("a dangling template link is the user's, got %q", steps["theme template"]) + } + if steps := statuses(setup.remove()); steps["theme template"] != "kept" { + t.Errorf("remove must not delete a link whose ownership it cannot read, got %q", steps["theme template"]) + } + if info, err := os.Lstat(env.templatePath()); err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Error("the dangling symlink must survive") + } +} + +func TestBarStatusStaysDarkOnAMalformedGlobalConfig(t *testing.T) { + server := imboxServer(t, `[{"id": 1, "name": "Invoice #4021", "seen": false}]`) + defer server.Close() + t.Setenv("HEY_TOKEN", "test-token") + t.Setenv("HEY_NO_KEYRING", "1") + t.Setenv("HEY_BASE_URL", "") + configHome := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", configHome) + t.Setenv("XDG_STATE_HOME", t.TempDir()) + if err := os.MkdirAll(filepath.Join(configHome, "hey-cli"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(configHome, "hey-cli", "config.json"), []byte("{broken"), 0o644); err != nil { + t.Fatal(err) + } + + root := newRootCmd() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs([]string{"omarchy", "bar-status", "--base-url", server.URL}) + if err := root.Execute(); err != nil { + t.Fatalf("a broken global config must not make the poller exit nonzero, got %v", err) + } + if buf.String() != "" { + t.Errorf("with no trustworthy configuration the poller must stay dark, not light against a guessed server: %q", buf.String()) + } + + root = newRootCmd() + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs([]string{"boxes"}) + if err := root.Execute(); err == nil { + t.Error("ordinary commands still report a broken global config") + } +} + +func TestOmarchySetupKeepsForeignTemplate(t *testing.T) { + env, ran := testOmarchyEnv(t) + foreign := "# my own hey theme template\naccent = \"#ff00ff\"\n" + if err := os.MkdirAll(filepath.Dir(env.templatePath()), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(env.templatePath(), []byte(foreign), 0o644); err != nil { + t.Fatal(err) + } + + setup := omarchySetup{env: env} + if steps := statuses(setup.apply()); steps["theme template"] != "kept" { + t.Errorf("a template hey did not write must be kept, got %q", steps["theme template"]) + } + if readText(t, env.templatePath()) != foreign { + t.Error("foreign template was overwritten") + } + if steps := statuses(setup.remove()); steps["theme template"] != "kept" { + t.Errorf("remove must leave a foreign template alone, got %q", steps["theme template"]) + } + if readText(t, env.templatePath()) != foreign { + t.Error("foreign template was deleted") + } + if len(*ran) != 0 { + t.Errorf("no theme refresh should run for a kept template, ran %v", *ran) + } +} + +func TestInsertMenuBlockSkipsBracesInComments(t *testing.T) { + content := "// A row looks like {\"icon\": \"x\"}.\n/* or a block: { nested } */\n{\n \"mine\": {},\n}\n" + next, ok := insertMenuBlock(content, omarchyMenuBlock()) + if !ok { + t.Fatal("insert failed") + } + if !strings.HasPrefix(next, "// A row looks like") { + t.Errorf("leading comments must be preserved above the block:\n%s", next) + } + if !strings.Contains(next, "*/\n{\n"+omarchyMenuBegin) { + t.Errorf("block must land after the structural brace, not a commented one:\n%s", next) + } + if _, ok := insertMenuBlock("// only a comment with { in it", omarchyMenuBlock()); ok { + t.Error("a file with no structural brace should be refused") + } + if _, ok := insertMenuBlock("[\n {\"rows\": {}}\n]\n", omarchyMenuBlock()); ok { + t.Error("an array root must be refused rather than having the block inserted into a nested object") + } + if _, ok := insertMenuBlock(" // leading comment\n {\"mine\": {}}\n", omarchyMenuBlock()); !ok { + t.Error("a commented object root is still an object root") + } +} + +func TestInsertMenuBlockReplacesStaleBlock(t *testing.T) { + stale := "{\n" + omarchyMenuBegin + "\n \"hey\": {\"label\":\"old\"},\n" + omarchyMenuEnd + "\n \"mine\": {},\n}\n" + next, ok := insertMenuBlock(stale, omarchyMenuBlock()) + if !ok { + t.Fatal("insert failed") + } + if strings.Count(next, omarchyMenuBegin) != 1 || strings.Contains(next, `"old"`) { + t.Errorf("stale block should be replaced:\n%s", next) + } + if !strings.Contains(next, `"mine"`) { + t.Errorf("user rows lost:\n%s", next) + } + if _, ok := insertMenuBlock("not json at all", omarchyMenuBlock()); ok { + t.Error("a file with no object should be refused") + } +} + +func TestSetupOmarchyRequiresOmarchy(t *testing.T) { + t.Setenv("HEY_NO_KEYRING", "1") + t.Setenv("HEY_BASE_URL", "") + t.Setenv("OMARCHY_PATH", "") + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + + root := newRootCmd() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs([]string{"setup", "omarchy", "--json"}) + if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "Omarchy not detected") { + t.Fatalf("expected detection error, got %v", err) + } +} + +func imboxServer(t *testing.T, postings string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/imbox.json": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id": 1, "name": "Imbox", "kind": "inbox", "postings": ` + postings + `}`)) + default: + w.WriteHeader(404) + } + })) +} + +func runBarStatus(t *testing.T, serverURL string, authenticated bool) (string, error) { + t.Helper() + if authenticated { + t.Setenv("HEY_TOKEN", "test-token") + } else { + t.Setenv("HEY_TOKEN", "") + } + t.Setenv("HEY_NO_KEYRING", "1") + t.Setenv("HEY_BASE_URL", "") + tmpDir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmpDir) + t.Setenv("XDG_STATE_HOME", tmpDir) + t.Setenv("XDG_CACHE_HOME", tmpDir) + + root := newRootCmd() + var buf bytes.Buffer + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs([]string{"omarchy", "bar-status", "--base-url", serverURL}) + err := root.Execute() + return buf.String(), err +} + +func TestBarStatusLightsOnUnread(t *testing.T) { + server := imboxServer(t, `[{"id": 1, "name": "Lunch on Thursday?", "seen": true}, {"id": 2, "name": "Invoice #4021", "seen": false}]`) + defer server.Close() + + out, err := runBarStatus(t, server.URL, true) + if err != nil { + t.Fatal(err) + } + var module map[string]any + if err := json.Unmarshal([]byte(out), &module); err != nil { + t.Fatalf("output is not JSON: %q", out) + } + if module["class"] != "active" || module["text"] != omarchyBarGlyph { + t.Errorf("unexpected module: %v", module) + } +} + +func TestBarStatusSilentWhenClear(t *testing.T) { + server := imboxServer(t, `[{"id": 1, "name": "Lunch on Thursday?", "seen": true}]`) + defer server.Close() + + out, err := runBarStatus(t, server.URL, true) + if err != nil || out != "" { + t.Errorf("clear imbox should print nothing and succeed, got %q, %v", out, err) + } +} + +func TestBarStatusSilentWhenUnauthenticatedOrOffline(t *testing.T) { + server := imboxServer(t, `[]`) + defer server.Close() + + out, err := runBarStatus(t, server.URL, false) + if err != nil || out != "" { + t.Errorf("logged out should print nothing and succeed, got %q, %v", out, err) + } + + server.Close() + out, err = runBarStatus(t, server.URL, true) + if err != nil || out != "" { + t.Errorf("offline should print nothing and succeed, got %q, %v", out, err) + } +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 38e0cccc..d4b2d2fc 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -74,7 +74,18 @@ func newRootCmd() *cobra.Command { } var err error - cfg, err = config.Load() + configDegraded = false + if commandIgnoresLocalConfig(cmd) { + // These commands never fail on configuration: a malformed global + // file leaves them on the baseline defaults, where the bar poller + // simply finds no credentials and stays dark. + if cfg, err = config.LoadGlobal(); err != nil { + cfg, err = config.Defaults(), nil + configDegraded = true + } + } else { + cfg, err = config.Load() + } if err != nil { return err } @@ -175,6 +186,7 @@ func newRootCmd() *cobra.Command { root.AddCommand(newIgnoreCommand().cmd) root.AddCommand(newStopIgnoringCommand().cmd) root.AddCommand(newSetupCommand()) + root.AddCommand(newOmarchyCommand().cmd) root.AddCommand(newTuiCommand().cmd) root.AddCommand(newHeyCommand().cmd) root.AddCommand(newSkillCommand().cmd) @@ -194,7 +206,9 @@ func commandUsesAccountScope(cmd *cobra.Command) bool { return true } switch parts[1] { - case "accounts", "auth", "commands", "completion", "config", "doctor", "setup", "skill", "upgrade", "version": + // omarchy is exempt because its bar-status must never fail: it selects the + // configured account itself and treats a failed selection as a dark indicator. + case "accounts", "auth", "commands", "completion", "config", "doctor", "omarchy", "setup", "skill", "upgrade", "version": return false default: return true diff --git a/internal/cmd/setup.go b/internal/cmd/setup.go index cfa09b8a..5ff2d5d9 100644 --- a/internal/cmd/setup.go +++ b/internal/cmd/setup.go @@ -12,7 +12,7 @@ import ( ) func newSetupCommand() *cobra.Command { - return &cobra.Command{ + setup := &cobra.Command{ Use: "setup", Short: "Set up HEY for first use", Long: "Sign in and prepare HEY for first use.", @@ -63,4 +63,6 @@ func newSetupCommand() *cobra.Command { ) }, } + setup.AddCommand(newSetupOmarchyCommand().cmd) + return setup } diff --git a/internal/config/config.go b/internal/config/config.go index 71706780..3b1d67c4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -126,8 +126,25 @@ func localConfigPath() string { return "" } +// Load reads the effective configuration: defaults, the global file, a +// repository-local .hey/config.json found from the working directory, then the +// environment. func Load() (*Config, error) { - cfg := &Config{ + return load(localConfigPath()) +} + +// LoadGlobal reads the configuration without any repository-local file. It is +// for commands that run from an arbitrary working directory on the user's +// behalf — a desktop bar poller, say — where a checkout's config must neither +// redirect them nor trip the local-config trust gate. +func LoadGlobal() (*Config, error) { + return load("") +} + +// Defaults is the configuration before any file or environment is consulted: +// the fallback for commands that must run even when the global file is broken. +func Defaults() *Config { + return &Config{ BaseURL: defaultBase, AccountID: AllAccounts, sources: map[string]Source{ @@ -135,6 +152,10 @@ func Load() (*Config, error) { "account_id": SourceDefault, }, } +} + +func load(localPath string) (*Config, error) { + cfg := Defaults() global, err := readFileConfig(globalConfigPath()) if err != nil { @@ -147,7 +168,6 @@ func Load() (*Config, error) { } var local fileConfig - localPath := localConfigPath() if localPath != "" { local, err = readFileConfig(localPath) if err != nil { diff --git a/internal/output/writer.go b/internal/output/writer.go index 13fb2bf9..72d907e3 100644 --- a/internal/output/writer.go +++ b/internal/output/writer.go @@ -112,6 +112,7 @@ func (w *Writer) Err(err error) { Error: e.Message, Code: e.Code, Hint: e.Hint, + Meta: e.Meta, } enc := json.NewEncoder(w.opts.Stderr) enc.SetIndent("", " ") From 17de3b28f2ab27dfd0dbf4f86ad7cc942411aa78 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Thu, 20 Aug 2026 13:06:06 -0700 Subject: [PATCH 2/2] Document the Omarchy integration README gains an Omarchy section (theming is zero-setup; yay -S hey-cli; hey setup omarchy) and docs/omarchy.md records the full design: the accent and selection gates with their thresholds, the foot fresh-window caveat, the atomic-mv watch, the decisions (indicator not count, overlay not hex port, complement the shipped web app), follow-ups in order, and the anti-features so they stay anti. --- README.md | 13 +++++ docs/omarchy.md | 131 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 docs/omarchy.md diff --git a/README.md b/README.md index 8bed5f54..da0c1b49 100644 --- a/README.md +++ b/README.md @@ -350,6 +350,19 @@ read from `~/.local/state/omarchy/current/theme/`, and restyles live when you ru `omarchy theme set`. Set `HEY_THEME=/path/to/file.toml` to use your own overlay anywhere — an explicitly chosen file is trusted as written — or `NO_COLOR=1` to turn color off. +```bash +yay -S hey-cli # hey-cli is on the AUR +hey setup omarchy # install into the desktop +hey setup omarchy --remove # take it all out again +``` + +Setup installs a `HEY TUI` launcher entry, a `HEY` row in the SUPER+SPACE menu, a bar +indicator that lights when the Imbox has unread mail (no count, by design), and a +`hey.toml.tpl` theme template so theme authors can tune the overlay. It prints the +`bindings.lua` snippet for a keybinding rather than editing your file. Omarchy's shipped +HEY web app, its SUPER+SHIFT+E binding and the mailto handler are left untouched. +See [docs/omarchy.md](docs/omarchy.md) for the details and what is planned next. + ## Agent Skill hey-cli ships with an embedded agent skill so your agent can interact with HEY on your behalf. diff --git a/docs/omarchy.md b/docs/omarchy.md new file mode 100644 index 00000000..ea43daa5 --- /dev/null +++ b/docs/omarchy.md @@ -0,0 +1,131 @@ +# hey-cli on Omarchy + +hey-cli should feel like an installed app on [Omarchy](https://omarchy.org), the way btop +and lazydocker do, rather than a command you remember to type. This page records what +the integration does, the decisions behind it, and the landscape that was mapped but +deliberately left for later. + +## What ships + +### Live theming (zero setup) + +The TUI styles with ANSI-16 colors, so Omarchy's terminal retint on every theme switch +already restyles a running `hey` for free. On top of that the TUI lays an **accent +overlay** read from the active theme: + +| Source, first match wins | Keys read | +|---|---| +| `NO_COLOR` | disables color entirely | +| `HEY_THEME=/path/to/file.toml` | any of the keys below | +| `~/.local/state/omarchy/current/theme/hey.toml` | `mode`, `accent`, `selection`, `muted`, `foreground`, `error`, plus the gate's reference keys below | +| `~/.local/state/omarchy/current/theme/colors.toml` | `mode`, `accent`, `selection`, `muted`, `foreground`, `red`; `bright_foreground` wins over `foreground` for emphasis, and `background`, `blue`, `bright_blue` feed the accent readability gate | +| ANSI defaults | — | + +Only the keys a file provides override the defaults, and two of them are gated, because +a theme's `accent` is a UI tint that does not always work as a text highlight: + +- **accent** is used when it is visibly distinct from the emphasis text (kanagawa's accent + *is* its foreground) and at least as readable on the background as the theme's bright + blue (osaka-jade's jade is dimmer than its mint). Otherwise the cursor row keeps bright + blue — the theme's own, which is what the terminal's ANSI 12 already is. +- **selection** tints the cursor row only when the chosen accent reads on it at ≥ 4.5:1; + rose-pine and miasma get the accent row with no tint rather than mud. + +When the accent is rejected, the cursor row falls back to plain ANSI bright blue rather +than the theme's hex, so it always matches the palette the surrounding text renders in. +That distinction matters in foot: a running foot window keeps the ANSI palette it opened +with until a new window is opened, so after a theme switch the ANSI-16 body of the TUI +shows the old theme while the overlay colors are new. Judge a theme in a fresh window. + +The measurements behind both thresholds are in the PR that introduced them. A theme (or +user) that disagrees can override any of it: `~/.config/omarchy/themes//hey.toml` +overlays the official theme — e.g. osaka-jade's all-green palette gains a real highlight +with `accent = "#F7E8B2"`, its own selection-foreground cream. Overridden accent and +selection values still pass the same readability gates, because once Omarchy renders the +theme the TUI cannot tell a hand-written value from a machine-derived one. The escape +hatch is `HEY_THEME`: a file the user points at explicitly is trusted as written and +skips both gates. + +The TUI watches `~/.local/state/omarchy/current/` and restyles the frame after +`omarchy theme set`. The watch sits on the parent because `omarchy-theme-set` swaps the +whole `theme/` directory with an atomic `mv` — a watch inside it would die with the old +inode. Cached viewports (thread, calendar grid, contact detail, bulk-reply preview) are +re-rendered rather than recolored, because Kitty inline-image placeholders encode their +image IDs as foreground colors. + +When no theme file states a `mode`, the TUI asks the terminal for its background color +and picks black instead of bright white for emphasized text on light backgrounds. + +One-shot CLI output (`hey box`, tables) keeps inheriting the terminal palette and is +not themed — that is the point of ANSI. + +### `hey setup omarchy` + +Idempotent; `--remove` reverses every piece; each step is reported separately and one +failing step does not stop the others. + +| Piece | Where | Notes | +|---|---|---| +| Desktop entry | `~/.local/share/applications/HEY TUI.desktop` | Distinct from Omarchy's shipped `HEY.desktop` web app. Launches under app-id `org.omarchy.hey` | +| Menu row | marker block in `~/.config/omarchy/extensions/omarchy-menu.jsonc` | one root `HEY` row that focuses or launches the TUI; its guard is a PATH lookup, never network or `hey` itself. Becomes a submenu once there is more than one thing to open | +| Bar indicator | inline command module `hey-unread` in `~/.config/omarchy/shell.json` | runs `hey omarchy bar-status` every 3 minutes; click focuses or launches the TUI | +| Theme template | `~/.config/omarchy/themed/hey.toml.tpl` | renders `hey.toml` into every theme so theme authors can override the overlay; triggers `omarchy-theme-refresh` | +| Keybinding | printed, never written | `o.bind("SUPER + SHIFT + ALT + H", "HEY TUI", "omarchy-launch-or-focus-tui --app-id=org.omarchy.hey hey tui")`; SUPER+SHIFT+E keeps opening the web app unless you `hl.unbind` it. Spelled out rather than `{ tui = "hey tui" }` because the lua helper quotes that into one word and the app-id derived from it would never match | + +Every surface — launcher, menu, bar click, keybinding — uses the same app-id +(`org.omarchy.hey`) so they all focus one window. That is why the desktop entry is tiled +rather than `TUI.float`: the float class is shared by every floating TUI, and +focus-or-launch would grab whichever one was open. + +If the user's `shell.json` has no `bar.layout` yet, the default layout from +`$OMARCHY_PATH/config/omarchy/shell.json` is copied in first; the shell treats a missing +layout as "use the defaults", so adding one module means spelling out the rest. A +`shell.json` that is not plain JSON is left alone and the step reports failure. + +### `hey omarchy bar-status` + +Hidden command the bar module runs. Prints +`{"text":"","tooltip":"Unread in Imbox","class":"active"}` when the Imbox has unread +mail and nothing otherwise (the `text` is the nf-fa-envelope glyph U+F0E0, which most +browsers render as nothing — it is not empty). HEY orders Imbox postings unseen-first, +so one page decides: any unread mail is on page 1. Logged out or offline also prints nothing and exits 0 — a bar +is no place for an error message. Credentials come from the keyring or the +`credentials.json` fallback exactly as for any other command, so it works from the +shell's headless context; token refresh happens in-process. + +## Decisions + +- **Indicator, not count.** Pending screener mail is not what people mean by "important", + and a number is the attention treadmill HEY exists to end. The glyph lights or it does + not. +- **Accent overlay, not a full hex port.** Replacing the ANSI palette wholesale would + trade away the free adaptation terminals already provide, and basecamp-cli's full port + is the cautionary precedent. +- **Complement the shipped web app, never replace it.** Distinct desktop name, printed + keybinding, the mailto handler left alone. +- **No HTML scraping to feed widgets.** The indicator uses the same typed SDK read as + `hey box imbox`. + +## Follow-ups, in rough order + +1. **New-mail toasts** via `omarchy-notification-send --glyph --exec`, default-off, + sharing one poller with the bar so the Imbox is fetched once per interval. +2. **mailto: handler** that opens a floating compose (`hey compose --mailto`), opt-in + against the incumbent `omarchy-webapp-handler-hey`. +3. **Agent-triage digests**: `hey --json` feeding a system agent that emits sparse + toasts instead of per-message noise. +4. **Upstream contributions**: a `default/themed/hey.toml.tpl` PR alongside + `claude.json.tpl`; an Install-menu TUI row; possibly branching the mailto handler to + the TUI when installed. (An AUR package already ships: `yay -S hey-cli`, published by + the release workflow.) +5. **Shell plugin graduation** for the bar widget: `manifest.json`, a settings panel, + IPC refresh when a thread is archived from the TUI instead of waiting for the next + poll. + +## Anti-features, recorded + +- No unread **count**. +- No per-message notification firehose. +- No full hex theme port. +- No auto-editing `~/.config/hypr/bindings.lua`. +- No HTML scraping to feed widgets.