diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 6aa7960..25d5646 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,102 +1,117 @@ -{"_type": "issue", "id": "hm-68a", "title": "introduce an element that is to be used anywhere where i am typing a reference to a secret. it should autocomplete as i type, showing me the closest match based on levenshtein distance. the same code should be used to also add a 'did you mean X' when himitsu search returns no results", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-05-07T06:22:32Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-09T02:11:54Z", "dependency_count": 0, "dependent_count": 0, "comment_count": 0, "closed_at": "2026-05-09T02:11:54Z", "close_reason": "Implemented Levenshtein-based 'did you mean' for himitsu search and a reusable TUI autocomplete popup wired into the search view (Ctrl+Space).", "started_at": "2026-05-07T06:22:32Z"} -{"_type": "issue", "id": "hm-7ob", "title": "Update command palette layout", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-05-07T06:06:18Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-07T06:10:27Z", "started_at": "2026-05-07T06:06:18Z", "closed_at": "2026-05-07T06:10:27Z", "close_reason": "Updated command palette layout and copy", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-isi", "title": "add support for TUI hints - a small bit of text that can render in subtle text on the bottom left corner, sort of like a floating element", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-05-06T23:21:58Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-09T02:11:54Z", "dependency_count": 0, "dependent_count": 0, "comment_count": 0, "closed_at": "2026-05-09T02:11:54Z", "close_reason": "Added bottom-left ambient hint surface; new-secret form publishes step-aware tips for path/tags/expires_at.", "started_at": "2026-05-06T23:21:58Z"} -{"_type": "issue", "id": "hm-3w5", "title": "all form fields should have placeholders containing an example", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-05-06T23:20:58Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-09T02:11:54Z", "dependency_count": 0, "dependent_count": 0, "comment_count": 0, "closed_at": "2026-05-09T02:11:54Z", "close_reason": "All new-secret form fields (and the generic FormView) now render muted placeholder examples when empty + unfocused.", "started_at": "2026-05-06T23:20:58Z"} -{"_type": "issue", "id": "hm-3rr", "title": "add a submit button to the new secret tui, and add a confirm dialog when clicking esc that lets you save or discard", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-05-06T23:20:25Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-09T02:11:54Z", "dependency_count": 0, "dependent_count": 0, "comment_count": 0, "closed_at": "2026-05-09T02:11:54Z", "close_reason": "Added a final [ submit ] tab stop and an unsaved-changes confirm dialog that fires on Esc with any populated field.", "started_at": "2026-05-06T23:20:25Z"} -{"_type": "issue", "id": "hm-6if", "title": "Add tags support for secrets (search + env composition)", "description": "## Motivation\n\nToday a secret's only addressable axis is its path. That works for hierarchies (`prod/api/STRIPE_KEY`) but not for crosscutting groupings \u2014 \"everything PCI\", \"everything the mobile team owns\", \"rotate-this-quarter\". Users currently fake it with path conventions, which collide with the env-prefix model.\n\nAdd free-form tags to secrets so that:\n- An item can carry many tags (`[\"pci\", \"stripe\", \"mobile\"]`).\n- `himitsu search` and `himitsu ls` can filter by tag.\n- The env DSL can select by tag, so an env like `pci-prod` can be authored as `tag: pci` instead of enumerating paths.\n\n## Data model\n\nTags live on `SecretValue` (the encrypted payload), as `repeated string tags = 9` in `proto/secrets.proto`. They travel with the value, are written/read through the existing age envelope, and stay atomic with the secret they describe.\n\n`SecretEntry.tags` (already declared at `proto/secrets.proto:89`) is repurposed as the manifest projection of the same field \u2014 populated by listing code that decrypts to surface metadata, identical to how descriptions are currently surfaced in `cli::search::search_core`.\n\nBackwards compatibility: empty tags is the default; legacy envelopes round-trip through `crypto::secret_value::decode` unchanged.\n\nTag grammar: `[A-Za-z0-9_.-]+`, 1\u201364 chars, no whitespace, case-sensitive. Validated at write time.\n\n## Authoring surfaces\n\n### CLI\n- `himitsu set --tag --tag ...` \u2014 repeatable flag on `cli/set.rs`.\n- `himitsu write --tag ` \u2014 same flag wired through `cli/write.rs`.\n- `himitsu tag add ...` / `rm ...` / `list` \u2014 new subcommand at `rust/src/cli/tag.rs`, registered in `cli/mod.rs`. Mutates by decrypt \u2192 edit `tags` \u2192 re-encrypt \u2192 write.\n\n### TUI\n- `tui/views/new_secret.rs`: add a `Tags` step (comma-separated input, validated per char as the user types) between `Description` and `EnvKey`.\n- `tui/views/secret_viewer.rs`: render tags as chips in the metadata pane.\n- `tui/views/search.rs`: render tag chips next to each result row; clicking/pressing a chip refines the search.\n\n## Search & filter\n\n- `himitsu search [--tag t]...`: AND-semantics across tag flags; empty query + tags = \"all secrets with these tags\". Tags read off the same `Decoded` struct produced by `search_core` decrypt loop.\n- `himitsu ls --tag t [--tag t]...`: same AND filter, applied after path-prefix walk.\n- Query-string sugar (out of scope for this epic, file as follow-up): `tag:foo bar` syntax inside the freeform query.\n\n## Env DSL \u2014 new `Tag` entry\n\nExtend `EnvEntry` (`rust/src/config/mod.rs:191`) with a `Tag(String)` variant. YAML shapes the deserializer accepts (extending `EnvEntry::deserialize` at `mod.rs:215`):\n\n- Inline string with `tag:` prefix:\n ```yaml\n envs:\n pci-prod:\n - tag:pci\n - tag:prod\n ```\n AND-semantics across multiple `tag:` entries inside one env (consistent with CLI `--tag` repetition).\n\n- Map form for explicit alias-rename, parallel to existing alias:\n ```yaml\n envs:\n pci-prod:\n - { tag: pci } # all pci-tagged secrets, key = last path segment\n - { STRIPE: tag:stripe } # alias: env-key STRIPE := the (singular) stripe-tagged secret\n ```\n Map-form must error when more than one secret matches the tag (alias requires a single value).\n\nResolver (`rust/src/config/env_resolver.rs:63`) gains a parallel branch to `resolve_concrete` / `resolve_wildcard`: walk `available_secrets`, decrypt-or-cache to read `tags`, include those that match. Wildcard capture refs (`$1`) are not extended to tag entries in this epic \u2014 out of scope.\n\n## Out of scope\n- Query-string `tag:` sugar inside `search ` (file as follow-up after this lands).\n- Sharing/inheriting tags across stores via policy.\n- Tag autocomplete in the TUI tag input (nice-to-have follow-up).\n- Capture-ref interpolation inside `Tag` env entries.\n- Renaming/merging tags as a first-class operation (do it manually via `tag rm` + `tag add`).\n\n## Success criteria\n- `himitsu set foo --tag a --tag b` round-trips: `himitsu get foo` shows tags in the metadata block, `himitsu tag foo list` prints `a\\nb`.\n- `himitsu search --tag a` returns only secrets carrying tag `a`; same for `ls --tag a`.\n- `himitsu codegen ` against an env with `- tag:a` produces a SOPS bundle whose keys are exactly the secrets tagged `a`.\n- TUI new-secret form accepts comma-separated tags; secret-viewer renders them; search rows show tag chips.\n- All existing tests pass; new tests cover: tag validation, `EnvEntry` (de)serialization round-trip for both string and map forms, resolver behavior with tag entries, `search`/`ls` filter logic.\n- Legacy secrets (no tags field) decode unchanged.\n", "status": "closed", "priority": 2, "issue_type": "epic", "owner": "noreply@anthropic.com", "created_at": "2026-05-05T12:14:38Z", "created_by": "Claude", "updated_at": "2026-05-09T02:11:54Z", "dependency_count": 0, "dependent_count": 0, "comment_count": 0, "closed_at": "2026-05-09T02:11:54Z", "close_reason": "Tags support already shipped end-to-end across CLI (set/write/search/ls/tag), config (EnvEntry::Tag, env resolver), and TUI (new-secret Tags step, viewer chips, search row chips). Closing the meta-epic.", "started_at": "2026-05-05T12:14:38Z"} -{"_type": "issue", "id": "hm-scm", "title": "instead of adding a row and indenting items that have a common prefix, add a key to fold/unfold by 1 level, get rid of the extra row, and use a sutble accent to show when folded/unfolded", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-04T12:25:10Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-05T03:57:37Z", "started_at": "2026-05-04T12:25:35Z", "closed_at": "2026-05-05T03:57:37Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-bs1", "title": "footer items are not spaced evenly - right-most item should be right-aligned", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-04T12:12:52Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-04T12:14:30Z", "started_at": "2026-05-04T12:13:19Z", "closed_at": "2026-05-04T12:14:30Z", "close_reason": "render_distributed_footer now anchors first item Left and last item Right so the footer row reads as evenly distributed across the full width", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-dsa", "title": "ctrl+P command menu should contain all valid commands from the stateless CLI", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-04T12:12:25Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-05T03:57:37Z", "started_at": "2026-05-04T12:25:22Z", "closed_at": "2026-05-05T03:57:37Z", "close_reason": "Ctrl+P palette now lists every visible top-level CLI command. Wired commands (new/sync/rekey/join/add-remote/switch-store/envs/help/quit/toggle-store-column) keep their behavior. New entries (recipient ls/add/rm/show, remote list/remove/default, context show/set/clear, generate, export, check, docs, import, git) emit an info toast naming the equivalent CLI invocation; full TUI forms can be wired per-command as follow-up.", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-8jm", "title": "fix himitsu sync failing in the current state", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-04T12:09:41Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-04T12:17:24Z", "started_at": "2026-05-04T12:11:24Z", "closed_at": "2026-05-04T12:17:24Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-mw9", "title": "add any compatible commands from the stateless CLI to the command picker in the TUI", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-04T01:15:29Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-04T01:18:27Z", "started_at": "2026-05-04T01:15:44Z", "closed_at": "2026-05-04T01:18:27Z", "close_reason": "Added sync, rekey, and join commands to the TUI command palette", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-1wl", "title": "himitsu import should convert SOME_KEY to some-key. it should also allow me to do a simple glob filter like foo/* and only import matches, and then it should also let me (unless it does already) set a prefix like --to some/target which would create some/target/{foo,bar,...} or --to github:org/repo#foo would put them in the foo directory. also, --to foo/bar should be equivalent to --to foo/bar/ (the trailing slash is ignored)", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-04T01:01:12Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-04T01:07:04Z", "started_at": "2026-05-04T01:01:36Z", "closed_at": "2026-05-04T01:07:04Z", "close_reason": "Implemented key normalization (SOME_KEY\u2192some-key), --filter glob, --to prefix, and --keep-names opt-out", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-u0v", "title": "auto-detect if recipient list doesn't contain own age key", "description": "## Context\n in that case, the sync status should glow orange and tell you to run 'himitsu join' which would simply add own public key to the stores recipient list, commit, and push. github actions should then rekey in a self-serve way if it is configured with a key in secrets. adding this key to secrets is also something that himitsu can do for you automatically. this task can be seen as idempotently ensuring those 3 conditions are met", "notes": "Scope per description: idempotently ensure (1) own pubkey is in the store's recipient list, (2) GitHub Actions has a configured age key in repo secrets, (3) auto-rekey via Actions is wired up. Surface state via TUI store-health glow + 'himitsu join' command that performs (1)-(3). Significant product/UX design \u2014 defer until owner aligns on join command shape and Actions self-serve flow.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-03T21:56:09Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-03T22:12:52Z", "started_at": "2026-05-03T22:06:52Z", "closed_at": "2026-05-03T22:12:52Z", "close_reason": "Implemented himitsu join command and TUI NotRecipient health indicator", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-bqk", "title": "himitsu isn't automatically running init when user has no store", "notes": "Investigation 2026-05-03: tui::run (rust/src/tui/mod.rs:33) auto-launches the wizard only on missing key (~/.local/share/himitsu/key). Gap: a user with a key but no resolved store gets an empty dashboard. Fix would be to also trigger run_init_flow when ctx.store.as_os_str().is_empty(). Risk: run_init_flow currently calls run() recursively after the wizard completes (mod.rs:126); init can complete-and-Success without creating a store if --name/--project are not used in the wizard, which would create an infinite loop. Either (a) require --name in the wizard's success path, (b) only re-enter run() when wizard reports a store was created, or (c) return Ok(()) from run() when store is still empty post-wizard. Needs interactive testing.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-03T21:52:55Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-03T22:07:19Z", "started_at": "2026-05-03T22:05:14Z", "closed_at": "2026-05-03T22:07:19Z", "close_reason": "TUI now launches init flow when key exists but no store resolves, with post-wizard no-store guard.", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-4y5", "title": "Cross-repo ref: prompt to clone missing referenced store", "description": "When a qualified cross-repo reference (github:org/repo#path) hits a store that isn't checked out locally, prompt for confirmation before cloning. Interactive: y/N prompt (default no). Non-interactive (no TTY): error with hint to run remote add. Add --yes to bypass confirmation for scripted use. TUI: small modal.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-01T10:55:51Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-01T10:57:55Z", "started_at": "2026-05-01T10:56:38Z", "closed_at": "2026-05-01T10:57:55Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-w6c", "title": "Cross-repo ref: prefer github:org/repo#path syntax (accept legacy / form)", "description": "Switch the canonical cross-repo reference syntax from github:org/repo/path/to/secret to github:org/repo#path/to/secret to disambiguate the slug from the path. Parser must accept both forms; the # form is the canonical one (used in docs, error messages, codegen). The slash form continues to parse as a fallback.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-01T10:55:51Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-01T10:57:55Z", "started_at": "2026-05-01T10:55:55Z", "closed_at": "2026-05-01T10:57:55Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-k4c", "title": "init should have placeholder for config set to project if in a git repo, unless the user has not configured a global store yet", "description": "In the init wizard's remote-store step, when inside a git repo (or parent is), default the placeholder to the current repo's origin slug and write to /himitsu.yaml as project-scoped config. Always run the global step first (skipped if already configured), labeled 'configure global store', then run a separate 'configure project' step (skipped if not in a git repo).", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-05-01T10:29:47Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-01T10:42:26Z", "closed_at": "2026-05-01T10:42:26Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-anm", "title": "Expand README sync and store health docs", "description": "Document sync behavior, auto-commit/push, no-remote and not-pushed warnings, --no-push, auto_pull, and TUI store health states.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-01T00:51:23Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-03T22:01:09Z", "started_at": "2026-05-03T21:59:14Z", "closed_at": "2026-05-03T22:01:09Z", "close_reason": "Updated README: TUI-first quick start, search no-op --refresh wording, expanded import/export tool requirements (op, sops), new Configuration section (theme/keys/HIMITSU_TUI_THEME/auto_pull), new Sync & Store Health section (auto-commit/push, --no-push, store health states), new Demo & Recordings section (vhs regen, demo-vhs.gif vs tui-us-* roles, CI scratch behavior)", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-1lt", "title": "Make README quick start TUI-first", "description": "Revise README Quick Start so the primary path launches the TUI first, then presents CLI commands as scripting/automation alternatives.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-01T00:51:22Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-03T22:01:09Z", "started_at": "2026-05-03T21:59:15Z", "closed_at": "2026-05-03T22:01:09Z", "close_reason": "Updated README: TUI-first quick start, search no-op --refresh wording, expanded import/export tool requirements (op, sops), new Configuration section (theme/keys/HIMITSU_TUI_THEME/auto_pull), new Sync & Store Health section (auto-commit/push, --no-push, store health states), new Demo & Recordings section (vhs regen, demo-vhs.gif vs tui-us-* roles, CI scratch behavior)", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-qku", "title": "Document TUI theme and key configuration", "description": "Add README documentation for global config TUI settings, including tui.theme, tui.keys, accepted built-in themes, and HIMITSU_TUI_THEME override.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-01T00:51:22Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-03T22:01:09Z", "started_at": "2026-05-03T21:59:15Z", "closed_at": "2026-05-03T22:01:09Z", "close_reason": "Updated README: TUI-first quick start, search no-op --refresh wording, expanded import/export tool requirements (op, sops), new Configuration section (theme/keys/HIMITSU_TUI_THEME/auto_pull), new Sync & Store Health section (auto-commit/push, --no-push, store health states), new Demo & Recordings section (vhs regen, demo-vhs.gif vs tui-us-* roles, CI scratch behavior)", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-o89", "title": "Fix README search documentation", "description": "Update README search feature and command docs to reflect current behavior: search reads store files directly and --refresh is retained as a no-op compatibility flag, not a SQLite index rebuild.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-01T00:51:21Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-03T22:01:09Z", "started_at": "2026-05-03T21:59:16Z", "closed_at": "2026-05-03T22:01:09Z", "close_reason": "Updated README: TUI-first quick start, search no-op --refresh wording, expanded import/export tool requirements (op, sops), new Configuration section (theme/keys/HIMITSU_TUI_THEME/auto_pull), new Sync & Store Health section (auto-commit/push, --no-push, store health states), new Demo & Recordings section (vhs regen, demo-vhs.gif vs tui-us-* roles, CI scratch behavior)", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-1b7", "title": "Review README and stabilize demo sync state", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-05-01T00:37:30Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-01T00:40:56Z", "started_at": "2026-05-01T00:37:37Z", "closed_at": "2026-05-01T00:40:56Z", "close_reason": "Reviewed README and added demo sync-state override", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-2f9", "title": "Refresh VHS demo tapes", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-04-30T23:45:18Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-01T00:03:22Z", "started_at": "2026-04-30T23:45:23Z", "closed_at": "2026-05-01T00:03:22Z", "close_reason": "Refreshed VHS demo as sourced pure TUI tape and validated render", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-wqb", "title": "allow editing name of items", "description": "In the himitsu TUI, pressing Enter on an item opens an editor that lets you edit every field EXCEPT the item's name. Make the name field editable too.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-30T23:19:04Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-09T02:11:54Z", "started_at": "2026-04-30T23:23:03Z", "dependency_count": 0, "dependent_count": 0, "comment_count": 0, "closed_at": "2026-05-09T02:11:54Z", "close_reason": "Editing the path field in the secret-viewer edit doc now renames the secret while preserving created_at + history (covered by finish_edit_renames_secret_and_preserves_history)."} -{"_type": "issue", "id": "hm-6qj", "title": "revamp envs feature.", "description": "## Context\n envs should ultimately be stored like this:\n# config.yaml\n...\nenvs:\n my-env:\n - SOME_KEY: some-exact-match # optionally override env key\n - other-exact-match # uses env key set on item or fallsback to upper(replace(name, -,_)) (forward slash replaced with __)\n - arbitrary-glob/*\nmy-env-{dev,prod,stg}:\n - SOME_KEY: ref-requires-{} # requires specifying where to replace\n - some-group/item-name-{} # using the {} is not required if you have a better format\n\n\nTherefore, the env editor should have an initial browser where i can select an existing env or add a new one, then take me into a 2-panel layout where I am editing the left side as if it was hx and the right pane shows me a preview of what env would be generated based on matches and/or any errors. there should be fuzzy find support and some auto-complete like feature that lets me discover the right labels as im editing", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-30T23:16:44Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-09T02:11:54Z", "started_at": "2026-04-30T23:23:04Z", "dependency_count": 0, "dependent_count": 0, "comment_count": 0, "closed_at": "2026-05-09T02:11:54Z", "close_reason": "envs feature revamp shipped: 2-pane DSL editor with live preview pane, fuzzy-find autocomplete corpus over secret names/groups, scope-aware browser, wildcard envs."} -{"_type": "issue", "id": "hm-5l4", "title": "Highlight self in recipient ls", "description": "Update himitsu recipient ls output so recipient rows whose full age public key matches the current user's own public key are highlighted in TTY output without changing non-TTY/plain output.", "status": "closed", "priority": 2, "issue_type": "feature", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-30T12:26:21Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-30T12:28:59Z", "started_at": "2026-04-30T12:26:43Z", "closed_at": "2026-04-30T12:28:59Z", "close_reason": "Highlighted recipient ls rows whose full public key matches the local self public key in TTY output, preserving plain non-TTY output", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-cak", "title": "Fix search footer alignment and centering", "description": "Apply split-layout footer alignment to the search view and correct small-screen search canvas centering.", "status": "closed", "priority": 2, "issue_type": "bug", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-30T11:01:04Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-30T11:02:44Z", "started_at": "2026-04-30T11:01:33Z", "closed_at": "2026-04-30T11:02:44Z", "close_reason": "Aligned search footer right actions with split layout and replaced Layout-based canvas centering with deterministic centered Rect math", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-774", "title": "all screens should be the same size as the search view - apply whatever constraints the search view have globally", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-30T04:01:29Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-30T10:35:08Z", "started_at": "2026-04-30T10:29:32Z", "closed_at": "2026-04-30T10:35:08Z", "close_reason": "Added shared standard_canvas margin/max-size helper and applied search view constraints across TUI screens and overlays", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-w6y", "title": "Review and prune stale tests", "description": "Review the current test suite and remove tests that cover deprecated behavior, stale expectations, or backwards-compatibility-only behavior no longer relevant to the Rust rewrite.", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-04-30T03:52:16Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-30T04:03:23Z", "started_at": "2026-04-30T03:52:27Z", "closed_at": "2026-04-30T04:03:23Z", "close_reason": "Pruned stale/deprecated/backwards-compat test coverage and verified test suite passes", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-aqi", "title": "Submodule-aware commit/push in dispatcher", "description": "Enable single-store + git-submodule-for-includes workflow. Previously, himitsu set to a path inside a submodule would strand the file: submodule dirty, parent clean, nothing pushed. Dispatcher now commits submodules first, pushes them first, holds back parent push if any submodule push fails. auto_pull gains --recurse-submodules. Safe detached-HEAD auto-checkout only when HEAD matches origin default-branch tip.", "status": "closed", "priority": 2, "issue_type": "feature", "owner": "demo@himitsu.dev", "created_at": "2026-04-24T09:18:44Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-24T09:19:01Z", "closed_at": "2026-04-24T09:19:01Z", "close_reason": "Landed: list_submodules/has_unpushed_commits/ensure_on_branch helpers in git.rs; Context::commit walks dirty submodules then parent, Context::push submodules-first with hold-back on failure, pull_if_remote recurses. 4 new tests covering round-trip with real local bare remotes. 460 tests green.", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-fy3", "title": "himitsu git status should never have unstaged / working changes. every action should commit, and make the git history be essentially an append-only log", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-21T22:13:42Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-21T22:40:35Z", "closed_at": "2026-04-21T22:40:35Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-ic2", "title": "himitsu codegen emits .sops.yaml", "description": "Extend `himitsu codegen` (rust/src/cli/codegen.rs) to accept an env label and emit a valid SOPS file at `.sops.yaml`. Today codegen's `--env` flag is already threaded through (see rust/src/cli/codegen.rs:55-56) for language bindings; this task adds a new output mode where the env itself is the target.\n\n## CLI surface\n\n- `himitsu codegen ` \u2014 shorthand for `--lang sops --env --output .sops.yaml`.\n- `himitsu codegen --output path.yaml` \u2014 override output path.\n- Accepts concrete labels (`foo/dev`) and wildcards (`foo/*`); wildcard form emits one file per matched child (`foo-dev.sops.yaml`, `foo-prod.sops.yaml`), or a single merged file if `--merge` is given (decide in review).\n\n## Output format\n\n- Top-level keys come from the resolved env entries (hm-qjz), each mapped to its decrypted secret value.\n- File is encrypted via `sops` rules: either the project's existing `.sops.yaml` rules file or an inline `creation_rules` block derived from himitsu recipients (TBD \u2014 call out in design doc).\n- Generated file contains the same `AUTO-GENERATED` header banner codegen already emits (rust/src/cli/codegen.rs:537).\n\n## Acceptance\n\n- `himitsu codegen foo/dev` produces `foo-dev.sops.yaml` that `sops -d` can decrypt with the configured recipients.\n- Wildcard case produces the expected file set.\n- Errors clearly when the env label is unknown or when captures fail to resolve.", "notes": "## Integration decision (2026-04-21)\n\n**Shell out to the `sops` CLI.** Generate plaintext YAML from the resolved env tree, write it to the output path, then invoke `sops --encrypt --in-place `. Key/recipient resolution is delegated to the user's existing `.sops.yaml` rules file.\n\n- Requires `sops` on PATH \u2014 detect and emit a helpful error if missing.\n- No new crypto crate dependency.\n- Existing `AUTO-GENERATED` header banner applies to the plaintext emission before encryption.", "status": "closed", "priority": 2, "issue_type": "feature", "owner": "demo@himitsu.dev", "created_at": "2026-04-21T20:39:24Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-21T21:32:38Z", "closed_at": "2026-04-21T21:32:38Z", "close_reason": "Landed: codegen positional triggers sops path. Resolver->decrypt->YAML->sops encrypt-in-place. Missing-sops error has install hint. 6 new tests green, 1 ignored smoke.", "dependencies": [{"issue_id": "hm-ic2", "depends_on_id": "hm-20e", "type": "blocks", "created_at": "2026-04-21T13:39:42Z", "created_by": "Cooper Maruyama", "metadata": "{}"}, {"issue_id": "hm-ic2", "depends_on_id": "hm-r2e", "type": "parent", "created_at": "2026-04-21T13:39:45Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 1, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-20e", "title": "Env resolver: expand wildcards and capture references", "description": "Central pure-function resolver that both the TUI preview and `himitsu codegen ` depend on. Given the full env map + a target label, return the concrete resolved entries (key \u2192 secret path, no wildcards, no captures remaining).\n\n## Behavior\n\n- Input: `&Config.envs`, target label (e.g. `foo/*` or `foo/dev`).\n- Resolution:\n 1. If label is concrete, return its entries verbatim (Aliases already resolved to path refs).\n 2. If label ends in `/*`, enumerate sibling envs whose labels match `foo/` (or deeper \u2014 TBD in schema design), and for each child, substitute captures into the glob's entries (e.g. `\\$1` \u2192 the matched segment).\n 3. Merge overlapping keys: concrete child entries win over wildcard-inherited ones.\n- Output: `Vec` plus diagnostics for unresolved captures or missing targets.\n\n## Why a dedicated resolver\n\nCodegen (hm-nm9) and the TUI preview (hm-kli) both need identical semantics. Putting it in one place means users never see a mismatch between TUI preview and generated SOPS output.\n\n## Acceptance\n\n- Table-driven tests for: concrete label, wildcard with 0/1/N children, nested wildcards (if allowed by schema), captures in Alias paths, capture index out of range.\n- Resolver returns deterministic order (sort by key) so generated SOPS files are diff-friendly.", "notes": "## Output shape correction (2026-04-21)\n\nThe resolver returns a **nested tree**, not a flat `Vec`. Shape (proposed):\n\n```rust\nenum EnvNode {\n Leaf { secret_path: String },\n Branch(BTreeMap),\n}\n```\n\nReasoning: `foo/*` expansion over children `foo/bar` and `foo/bar/baz` must produce a single nested structure, not a flat list and not multiple resolver invocations. Captures (`\\$1`) still substitute path segments, but the output is always a tree rooted at the queried label.\n\nGlob entries reference secret variables only \u2014 never other env groups. The resolver does not follow cross-env references.\n\nDeterminism: `BTreeMap` gives sorted keys \u2192 diff-friendly downstream codegen.", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-04-21T20:39:15Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-21T21:12:58Z", "closed_at": "2026-04-21T21:12:58Z", "close_reason": "Wave 2 landed in commit. Resolver returns deterministic EnvNode trees for concrete + wildcard labels. Cache mirrors envs into SQLite with scope isolation, SHA-256 staleness, atomic refresh. 18 new tests green.", "dependencies": [{"issue_id": "hm-20e", "depends_on_id": "hm-5v4", "type": "blocks", "created_at": "2026-04-21T13:39:39Z", "created_by": "Cooper Maruyama", "metadata": "{}"}, {"issue_id": "hm-20e", "depends_on_id": "hm-r2e", "type": "parent", "created_at": "2026-04-21T13:39:44Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 1, "dependent_count": 2, "comment_count": 0} -{"_type": "issue", "id": "hm-zzj", "title": "TUI Envs tab: list, create, edit, delete presets", "description": "Add a dedicated Envs tab alongside the existing TUI views (see rust/src/tui/views/). It becomes the primary UX for authoring preset environments.\n\n## UX sketch\n\n- Left pane: tree of env labels grouped by scope (project first, then global). `foo`, `foo/bar`, `foo/*` render as a hierarchy.\n- Right pane: the selected env's resolved entries \u2014 Singles, Aliases, and the expanded form of any Glob (so users see what `foo/*` actually produces).\n- Keymap reuses the project's existing conventions (rust/src/tui/keymap.rs): `n` new env, `e` edit, `d` delete, `/` search, `Enter` drill in.\n- Inline editor for entry kinds (Single | Glob | Alias) with a live preview column showing capture-group expansions.\n\n## Wiring\n\n- Reads come from the SQLite cache (hm-8ht).\n- Writes go through the mutation layer (hm-d1m); optimistic UI with toast rollback on failure.\n- Scope indicator visible at all times; creation defaults to project scope inside a git repo, global otherwise.\n\n## Acceptance\n\n- Can create `foo`, `foo/bar`, and `foo/*` entries end-to-end from the TUI.\n- Editing an Alias with a capture ref renders the live expansion for each matched child env.\n- Deleting an env removes it from YAML + cache on disk.", "notes": "## Scope reduction (2026-04-21)\n\n**First slice: read-only + delete.** Creation and inline editing are deferred to follow-up issues (to be filed when this one lands).\n\nIncluded in v1:\n- Tree/list view of envs grouped by scope (project/global).\n- Right pane shows resolved entries (Singles, Aliases, expanded Globs via hm-20e).\n- `d` deletes the selected env (with confirmation), routed through the mutation API (hm-d1m).\n- `/` search, scope indicator, navigation.\n\nDeferred to follow-ups:\n- `n` new env flow with inline editor.\n- `e` edit existing entries.\n- Live capture-reference preview editor.\n\nRationale: smallest slice that validates the cache + resolver + mutation stack end-to-end through real UI. Creation UX deserves its own design pass once read-path is proven.", "status": "closed", "priority": 2, "issue_type": "feature", "owner": "demo@himitsu.dev", "created_at": "2026-04-21T20:39:06Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-21T21:57:31Z", "closed_at": "2026-04-21T21:57:31Z", "close_reason": "Read+delete TUI tab landed. Epic complete: schema + cache + resolver + mutation + codegen sops + TUI all merged. 5 new view tests; 337 lib tests pass.", "dependencies": [{"issue_id": "hm-zzj", "depends_on_id": "hm-20e", "type": "blocks", "created_at": "2026-04-21T13:39:41Z", "created_by": "Cooper Maruyama", "metadata": "{}"}, {"issue_id": "hm-zzj", "depends_on_id": "hm-8ht", "type": "blocks", "created_at": "2026-04-21T13:39:40Z", "created_by": "Cooper Maruyama", "metadata": "{}"}, {"issue_id": "hm-zzj", "depends_on_id": "hm-d1m", "type": "blocks", "created_at": "2026-04-21T13:39:40Z", "created_by": "Cooper Maruyama", "metadata": "{}"}, {"issue_id": "hm-zzj", "depends_on_id": "hm-r2e", "type": "parent", "created_at": "2026-04-21T13:39:45Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 3, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-d1m", "title": "Config mutation layer: write envs to project or global scope", "description": "Add a mutation API that the TUI and any future CLI subcommands call to create/update/delete env presets. Scope is inferred the same way `codegen` already does it (rust/src/cli/codegen.rs:118-127): walk up for `.himitsu.yaml`, else fall back to global config.\n\n## Requirements\n\n- Single entry point: `envs::upsert(label, entries, scope_hint) -> Result<()>`; sibling `envs::delete(label, scope)`.\n- Preserve YAML formatting/comments where feasible (serde_yaml round-trip is lossy \u2014 evaluate `yaml-rust2` or string-level edits if comments matter here).\n- Atomic writes: temp file + rename so a crash mid-write does not corrupt config.\n- After every mutation, refresh the SQLite cache row for that scope.\n- Validation: reject labels that violate the grammar from the schema-design task.\n\n## Acceptance\n\n- Unit tests for project vs global routing, label validation, atomic write behavior.\n- Integration test: mutate via API \u2192 reload `Config` from disk \u2192 observe change.", "notes": "## YAML fidelity decision (2026-04-21)\n\n**Accept lossy round-trip via `serde_yaml`.** Comments and custom formatting in `.himitsu.yaml` / global config will not survive a mutation. This matches the current codebase pattern and keeps the implementation small.\n\n- Document the lossy behavior in the TUI help panel + user-facing docs.\n- Atomic write (temp file + rename) still required to avoid partial writes.", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-04-21T20:38:54Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-21T21:23:55Z", "closed_at": "2026-04-21T21:23:55Z", "close_reason": "Landed: upsert/delete/read API with ScopeHint inference, atomic writes, validation, cache refresh. Config gained first-class envs field. 11 new tests green.", "dependencies": [{"issue_id": "hm-d1m", "depends_on_id": "hm-5v4", "type": "blocks", "created_at": "2026-04-21T13:39:37Z", "created_by": "Cooper Maruyama", "metadata": "{}"}, {"issue_id": "hm-d1m", "depends_on_id": "hm-8ht", "type": "blocks", "created_at": "2026-04-21T13:39:38Z", "created_by": "Cooper Maruyama", "metadata": "{}"}, {"issue_id": "hm-d1m", "depends_on_id": "hm-r2e", "type": "parent", "created_at": "2026-04-21T13:39:44Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 2, "dependent_count": 1, "comment_count": 0} -{"_type": "issue", "id": "hm-8ht", "title": "SQLite cache for env presets (project + global scope)", "description": "Mirror `envs:` from YAML config into SQLite so the TUI can list/search without re-parsing YAML on every keystroke, and so other subsystems (codegen, check) have a uniform read API.\n\n## Requirements\n\n- Tables (proposed):\n - `envs(id INTEGER PK, label TEXT, scope TEXT CHECK(scope IN ('project','global')), config_path TEXT, mtime INTEGER)`\n - `env_entries(id INTEGER PK, env_id INTEGER REFERENCES envs(id) ON DELETE CASCADE, kind TEXT, value TEXT, alias_key TEXT)`\n- Rebuild strategy: on load, compare config file hash/mtime against cached row; rebuild if stale.\n- Scope handling: project rows keyed by git-root path so switching projects does not pollute.\n- YAML remains source of truth \u2014 every write path must rewrite YAML then refresh the cache; never the reverse.\n\n## Non-goals\n\n- No schema migrations framework yet \u2014 initial table ships with the feature.\n- No cross-device sync; the cache is purely local.\n\n## Acceptance\n\n- Round-trip test: load YAML \u2192 cache \u2192 query \u2192 matches parsed `Config.envs`.\n- Stale detection: mutating YAML on disk triggers cache rebuild on next read.", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-04-21T20:38:47Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-21T21:12:58Z", "closed_at": "2026-04-21T21:12:58Z", "close_reason": "Wave 2 landed in commit. Resolver returns deterministic EnvNode trees for concrete + wildcard labels. Cache mirrors envs into SQLite with scope isolation, SHA-256 staleness, atomic refresh. 18 new tests green.", "dependencies": [{"issue_id": "hm-8ht", "depends_on_id": "hm-5v4", "type": "blocks", "created_at": "2026-04-21T13:39:36Z", "created_by": "Cooper Maruyama", "metadata": "{}"}, {"issue_id": "hm-8ht", "depends_on_id": "hm-r2e", "type": "parent", "created_at": "2026-04-21T13:39:43Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 1, "dependent_count": 2, "comment_count": 0} -{"_type": "issue", "id": "hm-5v4", "title": "Design env schema: wildcard semantics + capture references", "description": "Formalize the env label/entry model before any TUI or codegen work lands. The current `EnvEntry` enum (rust/src/config/mod.rs:134) has `Single`, `Glob`, `Alias` \u2014 decide how capture references like `/\\$1/postgres-url` are expressed on-disk and in-memory.\n\n## Questions to resolve\n\n- Label grammar: confirm `foo`, `foo/bar`, `foo/*` are the only legal forms; reject `foo/*/bar`? Or allow mid-path wildcards?\n- Does a `foo/*` env enumerate its children by scanning other env labels in config, or by scanning the secret namespace? (Probably: scan sibling env labels that match `foo/`.)\n- Capture-ref syntax: `\\$1`, `\\${1}`, `{{1}}`? Which path segments are captured \u2014 everything matched by `*`?\n- Do captures only make sense inside `Glob` entries, or also inside `Alias.path`? Likely the latter: `Alias { key: DB_URL, path: \\\"/\\$1/postgres-url\\\" }` under a `foo/*` env.\n- Round-trip serde: new `EnvEntry` variant or reuse existing `Glob`/`Alias` with pattern detection?\n\n## Deliverable\n\nA short design doc (in this issue's `--design`) + updated serde tests in rust/src/config/mod.rs covering:\n- Capture-ref in Alias paths\n- Invalid label rejection\n- Expansion semantics (input env set \u2192 resolved concrete entries)", "notes": "## Locked constraints (2026-04-21)\n\n- **Reject mid-path wildcards.** Only `foo`, `foo/bar`, `foo/*` are valid. `foo/*/bar`, `*/foo`, and similar are hard errors at config-parse time.\n- **Glob entries reference secret variables only, never other env groups.** Inside a `foo/*` env, entries describe secret paths (optionally with `\\$1` captures) \u2014 they do not pull in other env labels.\n- **Wildcards produce nested output, never multiple files.** `foo/*` expanded over concrete children `foo/bar`, `foo/bar/baz` yields a single tree:\n ```\n foo:\n bar:\n baz: \n ```\n This mirrors how sops would naturally represent a hierarchical namespace.\n\nThese constraints must be enforced by the label grammar validator and reflected in the resolver's output shape (which is a tree, not a flat list \u2014 see hm-20e).", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-21T20:38:39Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-21T20:51:37Z", "closed_at": "2026-04-21T20:51:37Z", "close_reason": "Schema + validator landed: validate_env_label, parse_captures, validate_envs, ProjectConfig::validate. 15 new tests green, full workspace builds clean. Mid-path wildcards, bare-wildcard, bad chars, capture-in-concrete-env, and high-capture-index all rejected. Resolver (hm-20e) and codegen (hm-ic2) can now consume these helpers.", "dependencies": [{"issue_id": "hm-5v4", "depends_on_id": "hm-r2e", "type": "parent", "created_at": "2026-04-21T13:39:43Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 0, "dependent_count": 3, "comment_count": 0} -{"_type": "issue", "id": "hm-r2e", "title": "Preset environment management (TUI tab + codegen integration)", "description": "## Motivation\n\nUsers already declare environment presets in config under `envs:` as a `BTreeMap>` with `Single`, `Glob`, and `Alias` variants (see rust/src/config/mod.rs:116, :134). Today this config is only consumable by read paths \u2014 there is no UX for authoring, visualizing, or sharing envs, and `himitsu codegen` currently emits language bindings rather than deployable secret bundles.\n\nThis epic adds a first-class preset environment manager that:\n- Surfaces envs as a dedicated tab in the TUI\n- Treats env labels as a path namespace: `foo`, `foo/bar`, `foo/*` are all valid, where `foo/*` is a wildcard shortcut that expands across children\n- Supports capture-group references inside wildcard entries, e.g. `/$1/postgres-url` lets `foo/*` expand into `foo/dev/postgres-url`, `foo/prod/postgres-url`, etc.\n- Mutates the correct config file based on context (project `.himitsu.yaml` vs global config)\n- Mirrors the config into the SQLite cache so reads are fast and the TUI stays responsive\n- Extends `himitsu codegen ` to emit `.sops.yaml` \u2014 a valid SOPS-encrypted secret bundle \u2014 so envs become deployable artifacts, not just labels\n\n## Data model\n\nConceptually this is a many-to-many relationship: envs \u2194 secrets. In SQLite this is cached as (example, subject to design child):\n\n- `envs(id, label, scope)` \u2014 `scope` \u2208 {project, global}; `label` stores the full namespaced path (`foo`, `foo/bar`)\n- `env_entries(env_id, kind, value, alias_key, capture_pattern)` \u2014 one row per EnvEntry, preserving Single/Glob/Alias semantics\n\nAuthoritative storage stays YAML; SQLite is a rebuilt cache keyed off config file mtime/hash.\n\n## Scope boundary\n\n- Scope (project vs global) is inferred the same way `codegen` already does it \u2014 walk up for `.himitsu.yaml`, else global.\n- All mutations are writes to the YAML config plus a cache-refresh; no new storage of truth in SQLite.\n\n## Out of scope\n\n- Changing how secrets themselves are stored/encrypted\n- Re-keying or rotation flows (separate surface)\n- Any non-SOPS codegen output format (typescript/go/etc. stay as-is)\n\n## Success criteria\n\n- TUI has an Envs tab that lists, creates, edits, and deletes env presets with wildcard + alias support\n- Wildcard capture references (`$1`, `$2`, ...) resolve correctly in both TUI preview and codegen\n- `himitsu codegen ` produces a `.sops.yaml` that round-trips through `sops` without manual editing\n- SQLite cache stays consistent with YAML after every mutation; restart reflects on-disk state", "status": "closed", "priority": 2, "issue_type": "epic", "owner": "demo@himitsu.dev", "created_at": "2026-04-21T20:38:26Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-21T21:57:31Z", "closed_at": "2026-04-21T21:57:31Z", "close_reason": "Read+delete TUI tab landed. Epic complete: schema + cache + resolver + mutation + codegen sops + TUI all merged. 5 new view tests; 337 lib tests pass.", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-vy9", "title": "remove reference to legacy 'groups' command - use recipients in a path-based manner instead, e.g. team/person or team/*", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-21T20:26:12Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-21T20:34:51Z", "closed_at": "2026-04-21T20:34:51Z", "close_reason": "removed group references from README and CLI help, updated to path-based recipients", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-czj", "title": "TUI details/edit should support arbitrary fields (key/value pairs)", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-18T19:31:36Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-18T19:41:28Z", "closed_at": "2026-04-18T19:41:28Z", "close_reason": "annotations wired through decode/encode, TUI viewer displays them, edit doc accepts custom key:value as annotations", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-tz3", "title": "TUI should display status of remote store (if its out of sync/unhealthy)", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-18T15:53:59Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-18T16:26:20Z", "closed_at": "2026-04-18T16:26:20Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-6mo", "title": "get rid of groups. recipients should allow for path-based names which would allow the equivalent of groups by just using foo/*", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-18T15:53:12Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-18T16:26:21Z", "closed_at": "2026-04-18T16:26:21Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-6nb", "title": "add export command that lets you create SOPS-compatble files, mapping multiple keys to secrets, with support for foo/bar/* which would include all matching secrets with their default key", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-18T15:51:13Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-18T16:26:21Z", "closed_at": "2026-04-18T16:26:21Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-jk1", "title": "add support for SOPS to import", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-18T15:49:37Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-18T16:26:21Z", "closed_at": "2026-04-18T16:26:21Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-far", "title": "import command should allow importing the whole vault, including a dry run", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-18T15:49:05Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-18T16:26:21Z", "closed_at": "2026-04-18T16:26:21Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-yxb", "title": "search should show time ago format for created", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-04-18T15:38:47Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-18T15:54:48Z", "closed_at": "2026-04-18T15:54:48Z", "close_reason": "Implemented in TUI search view: humanized dates via humanize_age() and description column added", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-zzu", "title": "search page should show excerpt of description.", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-04-18T15:38:26Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-18T15:54:48Z", "closed_at": "2026-04-18T15:54:48Z", "close_reason": "Implemented in TUI search view: humanized dates via humanize_age() and description column added", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-pmy", "title": "every mutation should trigger a git sync to remote if configured", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-18T15:23:14Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-18T16:26:21Z", "closed_at": "2026-04-18T16:26:21Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-bln", "title": "himitsu 'get/read' completions should show available secrets, not files in the current directory", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-15T14:11:07Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-17T07:14:55Z", "closed_at": "2026-04-17T07:14:55Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-p8g", "title": "add import command, start with import from 1password via op cli", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-15T14:09:11Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-17T07:14:57Z", "closed_at": "2026-04-17T07:14:57Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-t81", "title": "use 'n days ago' format for date in search. include description, and put store as last column.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-15T14:08:28Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-17T07:14:58Z", "closed_at": "2026-04-17T07:14:58Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-f6z", "title": "TUI: integration test harness driving App via simulated key events", "description": "Build a test harness that constructs App, feeds a sequence of crossterm::event::KeyEvent through the main loop tick, and asserts on rendered buffer state. Enables end-to-end coverage of search\u2192viewer\u2192new-secret flows without spawning a real terminal.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-15T06:53:32Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-15T07:01:04Z", "closed_at": "2026-04-15T07:01:04Z", "close_reason": "Merged via parallel worktree agents; cargo test --bin himitsu tui::harness:: \u2192 3 passed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-3cm", "title": "TUI: configurable keybindings via config file", "description": "Allow users to remap TUI key bindings through the himitsu config file. Define a KeyMap struct, default to current bindings, let config override by action name (e.g. 'quit', 'new_secret', 'switch_store'). Threaded through App to each view's handle_key.", "status": "closed", "priority": 2, "issue_type": "feature", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-15T06:53:30Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-15T07:19:59Z", "closed_at": "2026-04-15T07:19:59Z", "close_reason": "Merged to main; 231 tests pass (toast + configurable keymap). Conflicts in app.rs/search.rs/secret_viewer.rs resolved by keeping toast-based action returns on top of keymap dispatch", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-o15", "title": "TUI: status-line toast for transient feedback", "description": "Add a toast/status-line component that renders transient messages (saved, copied, deleted, error) for N seconds then clears. Currently feedback is inline-only in individual views. Should be globally mounted in App so every view can emit a toast.", "status": "closed", "priority": 2, "issue_type": "feature", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-15T06:53:21Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-15T07:19:59Z", "closed_at": "2026-04-15T07:19:59Z", "close_reason": "Merged to main; 231 tests pass (toast + configurable keymap). Conflicts in app.rs/search.rs/secret_viewer.rs resolved by keeping toast-based action returns on top of keymap dispatch", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-64c", "title": "record new demo after major tui changes", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-15T02:28:44Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-15T02:54:24Z", "closed_at": "2026-04-15T02:54:24Z", "close_reason": "Rewrote and re-recorded all 6 TUI tapes (us-008..us-013) against search-as-root + new-secret metadata + store picker flow", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-r4i", "title": "new secret flow should alow setting metadata, not just edit", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-14T14:23:45Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-15T02:46:54Z", "closed_at": "2026-04-15T02:46:54Z", "close_reason": "metadata fields (description/url/totp/env_key/expires_at) added to new-secret form with tab nav, validation, 13 new tests", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-y6n", "title": "provide alternative hotkey for ctrl+s to save, as it is commmonly set in tmux and conflicts", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-14T14:23:19Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-14T14:28:32Z", "closed_at": "2026-04-14T14:28:32Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-72k", "title": "get rid of 'env', its being used as a group which is incorrect. it shouldnt even show in the search tui. env is for codegen which I will spec out later", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-14T14:20:35Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-14T14:28:32Z", "closed_at": "2026-04-14T14:28:32Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-8z8", "title": "search view should have a hotkey for copying to clipboard", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T18:07:09Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-14T14:23:23Z", "closed_at": "2026-04-14T14:23:23Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-57k", "title": "tui should render all stores together, rendering the store as a column", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T18:06:03Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T18:11:43Z", "closed_at": "2026-04-13T18:11:43Z", "close_reason": "multi-store grouping with per-store header rows", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-rnx", "title": "TUI secret viewer should render all decoded metadata", "description": "The secret viewer in the TUI only shows path/env/created_at/lastmodified/recipients. It should also render description, url, totp, expires_at, and env_key from the decoded SecretValue so users can see everything they just edited without round-tripping through the editor.", "status": "closed", "priority": 2, "issue_type": "feature", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T18:05:44Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T18:12:51Z", "closed_at": "2026-04-13T18:12:51Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-ldq", "title": "Add env_key metadata field to SecretValue", "description": "Prepare for 'himitsu exec path/* -- cmd' by letting each secret carry a default environment variable name. When unset, callers derive one from the path (typically last segment uppercased). Description field already exists from hm-dvs/hm-de4.", "acceptance_criteria": "proto field added; --env-key flag on set; validates as POSIX env var name; surfaced by himitsu get; round-trips through encode/decode", "status": "closed", "priority": 2, "issue_type": "feature", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T17:20:50Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T17:21:05Z", "closed_at": "2026-04-13T17:21:05Z", "close_reason": "env_key field added end-to-end with validation + tests", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-tcp", "title": "columns should have header labels in tui", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T17:17:56Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T18:12:51Z", "closed_at": "2026-04-13T18:12:51Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-3ir", "title": "the default tui should use a search bar with fuzzy find instead of the two-column navigator", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T04:04:48Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T04:11:38Z", "closed_at": "2026-04-13T04:11:38Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-jjc", "title": "add fuzzy find to search", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T03:14:50Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:39:11Z", "closed_at": "2026-04-13T03:39:11Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-yu4", "title": "recipients should allow for adding metadata. adding a recipient should additionally prompt for a description (optional)", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T03:11:02Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:39:11Z", "closed_at": "2026-04-13T03:39:11Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-dvs", "title": "add a field called totp that can be used to associate a TOTP key for any secret. also add a URL field that can be used to store the website it is associated with", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T03:10:15Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:39:11Z", "closed_at": "2026-04-13T03:39:11Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-de4", "title": "add a expires_at field to metadata, can be optionally set to remind when the secret is about to expire", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T03:08:26Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:39:11Z", "closed_at": "2026-04-13T03:39:11Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-6xd", "title": ".himitsu/recipients should contain a flat list of recipients. the group should not be determined by folder nesting, but rather by a mapping in .himitsu.yaml - this solves the issue where a recipient cannot belong to multiple groups without duplicating files", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T03:06:39Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:39:11Z", "closed_at": "2026-04-13T03:39:11Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-ftf", "title": "implement himitsu read/write which are like get/set except they return just the plaintext secret, render no metadata, and are meant to be used in other tools", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T03:04:41Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:39:11Z", "closed_at": "2026-04-13T03:39:11Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-381", "title": "implement completions", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T03:02:56Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:39:11Z", "closed_at": "2026-04-13T03:39:11Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-28r", "title": "himitsu search should look similar to gh repo list", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T02:56:40Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:39:11Z", "closed_at": "2026-04-13T03:39:11Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-iq6", "title": "himitsu tui navigation should look more like a table that shows the path, description, last modified date, and store it belongs to. folders should be sorted first just like in the file explorer in most IDEs", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T02:40:26Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:51:25Z", "closed_at": "2026-04-13T03:51:25Z", "close_reason": "Closed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-6vb.6", "title": "US-013: Store/env switch from dashboard", "description": "Let the user switch the active store (recipients/data dir) without restarting the TUI. Add a store picker (e.g. 's' key) that lists configured stores from config and rebuilds the dashboard against the chosen store. Scope of 'env switch' is unclear \u2014 envs are derived from path prefixes, so selection already works; confirm with user whether this bead should also cover a true multi-context switch.", "notes": "Deliverable must include a demo artifact for the completed work: both a VHS tape (demo/*.tape \u2192 regenerated .gif) and an asciinema .cast recording that walks through the new flow end-to-end. Follow the existing demo/ conventions from the US-001..US-006 series.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T02:14:51Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:12:16Z", "closed_at": "2026-04-13T03:12:16Z", "close_reason": "Closed", "dependencies": [{"issue_id": "hm-6vb.6", "depends_on_id": "hm-6vb", "type": "parent-child", "created_at": "2026-04-12T19:14:50Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-6vb.3", "title": "US-011: Drill into secret from dashboard", "description": "Pressing Enter on a secret in the dashboard's right-hand list currently does nothing. Wire it to open the SecretViewerView directly, skipping the search detour. Also add j/k/arrow navigation within the secret list (currently only env list navigates).", "notes": "Deliverable must include a demo artifact for the completed work: both a VHS tape (demo/*.tape \u2192 regenerated .gif) and an asciinema .cast recording that walks through the new flow end-to-end. Follow the existing demo/ conventions from the US-001..US-006 series.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T02:14:40Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:12:15Z", "closed_at": "2026-04-13T03:12:15Z", "close_reason": "Closed", "dependencies": [{"issue_id": "hm-6vb.3", "depends_on_id": "hm-6vb", "type": "parent-child", "created_at": "2026-04-12T19:14:39Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-6vb.4", "title": "US-012: Help overlay ('?')", "description": "Add a modal help overlay bound to '?' (and maybe F1) that lists all keybindings for the current view. Should be view-aware: dashboard, search, and viewer each show their own bindings. Dismiss with Esc or '?'.", "notes": "Deliverable must include a demo artifact for the completed work: both a VHS tape (demo/*.tape \u2192 regenerated .gif) and an asciinema .cast recording that walks through the new flow end-to-end. Follow the existing demo/ conventions from the US-001..US-006 series.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T02:14:40Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:12:15Z", "closed_at": "2026-04-13T03:12:15Z", "close_reason": "Closed", "dependencies": [{"issue_id": "hm-6vb.4", "depends_on_id": "hm-6vb", "type": "parent-child", "created_at": "2026-04-12T19:14:39Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-6vb.5", "title": "US-008: Create secret from TUI", "description": "Add a 'new secret' flow reachable from the dashboard (e.g. 'n' keybinding). Should prompt for env/path and value (value input hidden), then call the same internal path as 'himitsu set' \u2014 no subprocess. On success, refresh the dashboard list and select the new entry.", "notes": "Deliverable must include a demo artifact for the completed work: both a VHS tape (demo/*.tape \u2192 regenerated .gif) and an asciinema .cast recording that walks through the new flow end-to-end. Follow the existing demo/ conventions from the US-001..US-006 series.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T02:14:40Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:12:15Z", "closed_at": "2026-04-13T03:12:15Z", "close_reason": "Closed", "dependencies": [{"issue_id": "hm-6vb.5", "depends_on_id": "hm-6vb", "type": "parent-child", "created_at": "2026-04-12T19:14:39Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-6vb.1", "title": "US-009: Delete secret from TUI", "description": "Add a delete action in the secret viewer and/or search result list (e.g. 'd' with a confirmation prompt). Wire to the existing store delete path. Refresh the parent view on success and surface errors as a status line.", "notes": "Deliverable must include a demo artifact for the completed work: both a VHS tape (demo/*.tape \u2192 regenerated .gif) and an asciinema .cast recording that walks through the new flow end-to-end. Follow the existing demo/ conventions from the US-001..US-006 series.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T02:14:39Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:12:15Z", "closed_at": "2026-04-13T03:12:15Z", "close_reason": "Closed", "dependencies": [{"issue_id": "hm-6vb.1", "depends_on_id": "hm-6vb", "type": "parent-child", "created_at": "2026-04-12T19:14:39Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-6vb.2", "title": "US-010: Edit secret value from TUI", "description": "Currently 'e' on the secret viewer rekeys \u2014 rebind rekey and add a real edit flow that decrypts the current value, opens it in an in-TUI editor (or $EDITOR fallback with terminal suspend, matching the init wizard pattern), and re-encrypts on save. Decide with the user whether to preserve 'e' for edit and move rekey to a less prominent key.", "notes": "Deliverable must include a demo artifact for the completed work: both a VHS tape (demo/*.tape \u2192 regenerated .gif) and an asciinema .cast recording that walks through the new flow end-to-end. Follow the existing demo/ conventions from the US-001..US-006 series.", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T02:14:39Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:12:15Z", "closed_at": "2026-04-13T03:12:15Z", "close_reason": "Closed", "dependencies": [{"issue_id": "hm-6vb.2", "depends_on_id": "hm-6vb", "type": "parent-child", "created_at": "2026-04-12T19:14:39Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-6vb", "title": "TUI Phase 2: CRUD + UX polish", "description": "Follow-on epic to hm-nck. The Rust/ratatui TUI shipped as a read-only explorer (dashboard \u2192 search \u2192 viewer). This epic adds the missing CRUD actions (create/edit/delete), first-class navigation from the dashboard, a help overlay, and store/env switching so the TUI stands on its own without shelling out to the CLI for common operations.", "status": "closed", "priority": 2, "issue_type": "epic", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T02:14:19Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T03:12:16Z", "closed_at": "2026-04-13T03:12:16Z", "close_reason": "all steps complete", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-c84", "title": "US-007: Global navigation and keybindings", "description": "Consistent global keybindings: q quits from dashboard (ignored in inputs), Ctrl-C always quits with terminal cleanup, Esc goes back one view, / enters search from dashboard. Footer hint bar on every view. Resize event re-lays out without panic. Event loop uses crossterm::event::poll with sane timeout (no busy spin).", "acceptance_criteria": "q/Ctrl-C/Esc/'/' bindings wired; footer hint on each view; resize handled; poll-based event loop", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T01:14:48Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T02:10:40Z", "closed_at": "2026-04-13T02:10:40Z", "close_reason": "Global nav + keybindings wired via DashboardAction router in 02af8ab. q/Ctrl-C/Esc/'/' bindings live; footer on every view; poll-based loop with 100ms timeout; resize auto-re-lays out via ratatui frame draw each tick.", "dependencies": [{"issue_id": "hm-c84", "depends_on_id": "hm-djm", "type": "blocks", "created_at": "2026-04-12T18:15:19Z", "created_by": "Cooper Maruyama", "metadata": "{}"}, {"issue_id": "hm-c84", "depends_on_id": "hm-nck", "type": "parent-child", "created_at": "2026-04-12T18:15:13Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 1, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-xe5", "title": "US-006: Secret viewer", "description": "Show path, env, recipients, created_at, lastmodified. Value hidden by default; 'r' reveals via existing get code path. 'y' copies to clipboard via arboard (graceful no-op if unavailable). 'e' triggers rekey for the current secret. Esc returns to previous view.", "acceptance_criteria": "metadata shown; r reveals value; y copies via arboard; e triggers rekey; esc returns", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T01:14:39Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T02:09:54Z", "closed_at": "2026-04-13T02:09:54Z", "close_reason": "Implemented SecretViewerView with reveal/copy/rekey/esc, wired into router via SearchAction::OpenViewer; 6 tests passing", "dependencies": [{"issue_id": "hm-xe5", "depends_on_id": "hm-nck", "type": "parent-child", "created_at": "2026-04-12T18:15:13Z", "created_by": "Cooper Maruyama", "metadata": "{}"}, {"issue_id": "hm-xe5", "depends_on_id": "hm-rd2", "type": "blocks", "created_at": "2026-04-12T18:15:20Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 1, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-pp9", "title": "US-005: Search view", "description": "/ from dashboard opens search with focused text input. Results update as query changes. Uses extracted core of crate::cli::search (refactor search::run to split IO from logic). Results show store, path, created_at. Enter on a result navigates to secret viewer. Esc returns to dashboard.", "acceptance_criteria": "search view opens on /; live results; uses extracted search core; Enter opens viewer; Esc returns to dashboard", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T01:14:32Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T02:09:46Z", "closed_at": "2026-04-13T02:09:46Z", "close_reason": "Landed in 02af8ab", "dependencies": [{"issue_id": "hm-pp9", "depends_on_id": "hm-nck", "type": "parent-child", "created_at": "2026-04-12T18:15:12Z", "created_by": "Cooper Maruyama", "metadata": "{}"}, {"issue_id": "hm-pp9", "depends_on_id": "hm-rd2", "type": "blocks", "created_at": "2026-04-12T18:15:19Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 1, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-rd2", "title": "US-004: Dashboard view (envs + secrets)", "description": "Two-pane layout: left = environments list, right = secrets for selected env. Header shows active store slug and env count. Arrow keys / j/k navigate envs. Right pane updates live on selection change. Data via internal Rust APIs (ls equivalent), not subprocess. Empty state message when no envs. Footer lists keybindings.", "acceptance_criteria": "two-pane layout; live right-pane updates; uses internal APIs not subprocess; empty state handled; footer keybindings shown", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T01:14:25Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T01:52:00Z", "closed_at": "2026-04-13T01:52:00Z", "close_reason": "US-003 init wizard + US-004 dashboard view landed; DashboardView ported to ratatui with envs/secrets two-pane layout, run_init_flow event loop wired.", "dependencies": [{"issue_id": "hm-rd2", "depends_on_id": "hm-djm", "type": "blocks", "created_at": "2026-04-12T18:15:19Z", "created_by": "Cooper Maruyama", "metadata": "{}"}, {"issue_id": "hm-rd2", "depends_on_id": "hm-nck", "type": "parent-child", "created_at": "2026-04-12T18:15:12Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 1, "dependent_count": 2, "comment_count": 0} -{"_type": "issue", "id": "hm-b49", "title": "US-003: Init wizard view (ratatui)", "description": "Port the 3-step init wizard to ratatui: Step 1 data dir text input pre-filled from config::data_dir(); Step 2 remote slug validated with config::validate_remote_slug; Step 3 key provider select (hidden if macOS Keychain unavailable). Enter advances, Esc back, Ctrl-C aborts. On confirm calls init::run_init directly (not subprocess). Success screen shows pubkey and advances to dashboard. Replaces init::run_wizard cliclack path when TTY and no --json/--no-tui.", "acceptance_criteria": "three steps rendered; validation wired; calls init::run_init directly; cliclack path removed for TTY case; success screen advances to dashboard", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T01:14:18Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T01:52:37Z", "closed_at": "2026-04-13T01:52:37Z", "close_reason": "ratatui init wizard landed: 3-step flow (data dir/remote slug/key provider), validation wired, calls init::run_init directly, cliclack TTY path removed, success advances to dashboard", "dependencies": [{"issue_id": "hm-b49", "depends_on_id": "hm-djm", "type": "blocks", "created_at": "2026-04-12T18:15:18Z", "created_by": "Cooper Maruyama", "metadata": "{}"}, {"issue_id": "hm-b49", "depends_on_id": "hm-nck", "type": "parent-child", "created_at": "2026-04-12T18:15:11Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 1, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-4y7", "title": "US-002: Remove legacy Bun/OpenTUI TUI", "description": "Delete the entire tui/ directory and remove himitsuTUI derivation + bun2nix input from flake.nix. Remove any himitsu-tui references from nativeBuildInputs and the HIMITSU_BIN env forwarding from launch_tui().", "acceptance_criteria": "tui/ directory deleted; flake.nix has no himitsuTUI/bun2nix refs; HIMITSU_BIN forwarding removed; nix build still succeeds", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T01:14:08Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T01:43:39Z", "closed_at": "2026-04-13T01:43:39Z", "close_reason": "Deleted tui/, dropped bun2nix input, removed himitsuTUI deriv. nix flake check + cargo test/clippy/fmt all green.", "dependencies": [{"issue_id": "hm-4y7", "depends_on_id": "hm-djm", "type": "blocks", "created_at": "2026-04-12T18:15:18Z", "created_by": "Cooper Maruyama", "metadata": "{}"}, {"issue_id": "hm-4y7", "depends_on_id": "hm-nck", "type": "parent-child", "created_at": "2026-04-12T18:15:11Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 1, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-djm", "title": "US-001: Scaffold ratatui module and wire up entry point", "description": "Add ratatui/crossterm to rust/Cargo.toml and create rust/src/tui/ with mod.rs, app.rs, event.rs. Public entry himitsu::tui::run() installs/tears down raw mode + alternate screen, runs a no-op event loop that exits on q or Ctrl-C. Cli::launch_tui() in rust/src/cli/mod.rs calls tui::run() directly instead of spawning himitsu-tui. Terminal state restored on panic via drop guard or set_hook.", "acceptance_criteria": "ratatui+crossterm in Cargo.toml; rust/src/tui/{mod,app,event}.rs created; himitsu::tui::run() exists; launch_tui calls it in-process; raw mode restored on panic", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T01:14:01Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T01:17:48Z", "closed_at": "2026-04-13T01:17:48Z", "close_reason": "Closed", "dependencies": [{"issue_id": "hm-djm", "depends_on_id": "hm-nck", "type": "parent-child", "created_at": "2026-04-12T18:15:11Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 0, "dependent_count": 4, "comment_count": 0} -{"_type": "issue", "id": "hm-nck", "title": "Replace OpenTUI TUI with Rust/ratatui", "description": "Replace the Bun + @opentui/core TUI (in tui/, launched as a subprocess via himitsu-tui) with a pure-Rust TUI built on ratatui + crossterm, living inside the existing himitsu binary at rust/src/tui/. Running 'himitsu' with no subcommand will launch the new TUI in-process, eliminating the subprocess hop, the HIMITSU_BIN fallback, and the entire Bun toolchain from the build. Full PRD in commit history / conversation.", "status": "closed", "priority": 2, "issue_type": "epic", "owner": "demo@himitsu.dev", "created_at": "2026-04-13T01:13:45Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T02:10:40Z", "closed_at": "2026-04-13T02:10:40Z", "close_reason": "all steps complete", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-xx0", "title": "himitsu 'add' should behave like an alias for himitsu 'set'", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-12T23:51:12Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T01:17:38Z", "closed_at": "2026-04-13T01:17:38Z", "close_reason": "Added clap alias='add' to Set command in rust/src/cli/mod.rs", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-3w1", "title": "running 'himitsu' with no args should launch the tui", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-04-12T23:50:49Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T01:17:38Z", "closed_at": "2026-04-13T01:17:38Z", "close_reason": "Already implemented \u2014 cdf8757 feat: launch TUI when himitsu is run with no subcommand", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-457", "title": "running 'himitsu' with no args should launch the tui", "status": "closed", "priority": 2, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-12T13:39:46Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-12T13:47:48Z", "closed_at": "2026-04-12T13:47:48Z", "close_reason": "Implemented: running himitsu with no args now launches the TUI (himitsu-tui binary). Falls back to a helpful error if TUI not installed.", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-anf", "title": "himitsu 'add' should behave like an alias for himitsu 'set'", "status": "closed", "priority": 2, "issue_type": "task", "owner": "demo@himitsu.dev", "created_at": "2026-04-12T13:38:51Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-13T01:17:38Z", "closed_at": "2026-04-13T01:17:38Z", "close_reason": "Added clap alias='add' to Set command in rust/src/cli/mod.rs", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-3us", "title": "Document external import/export requirements", "description": "Add README notes for external tool requirements used by import/export/generate flows, including op for 1Password and sops for encrypted YAML/JSON output.", "status": "closed", "priority": 3, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-01T00:51:24Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-03T22:01:09Z", "started_at": "2026-05-03T21:59:16Z", "closed_at": "2026-05-03T22:01:09Z", "close_reason": "Updated README: TUI-first quick start, search no-op --refresh wording, expanded import/export tool requirements (op, sops), new Configuration section (theme/keys/HIMITSU_TUI_THEME/auto_pull), new Sync & Store Health section (auto-commit/push, --no-push, store health states), new Demo & Recordings section (vhs regen, demo-vhs.gif vs tui-us-* roles, CI scratch behavior)", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-u7g", "title": "Add README demo recording instructions", "description": "Add a Demo or Recording section with the canonical local command to regenerate the VHS demo: cargo build --release && vhs demo/demo.tape, plus CI scratch-output behavior.", "status": "closed", "priority": 3, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-01T00:51:24Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-03T22:01:09Z", "started_at": "2026-05-03T21:59:16Z", "closed_at": "2026-05-03T22:01:09Z", "close_reason": "Updated README: TUI-first quick start, search no-op --refresh wording, expanded import/export tool requirements (op, sops), new Configuration section (theme/keys/HIMITSU_TUI_THEME/auto_pull), new Sync & Store Health section (auto-commit/push, --no-push, store health states), new Demo & Recordings section (vhs regen, demo-vhs.gif vs tui-us-* roles, CI scratch behavior)", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-9lg", "title": "Clarify README demo GIF and tape roles", "description": "Update README demo references to use demo/demo-vhs.gif as the canonical polished demo and explain demo/tui-us-* tapes as per-story regression/demo artifacts.", "status": "closed", "priority": 3, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-05-01T00:51:23Z", "created_by": "Cooper Maruyama", "updated_at": "2026-05-03T22:01:09Z", "started_at": "2026-05-03T21:59:17Z", "closed_at": "2026-05-03T22:01:09Z", "close_reason": "Updated README: TUI-first quick start, search no-op --refresh wording, expanded import/export tool requirements (op, sops), new Configuration section (theme/keys/HIMITSU_TUI_THEME/auto_pull), new Sync & Store Health section (auto-commit/push, --no-push, store health states), new Demo & Recordings section (vhs regen, demo-vhs.gif vs tui-us-* roles, CI scratch behavior)", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-cfo", "title": "Distribute TUI footer actions evenly", "description": "Render footer action groups in evenly distributed segments instead of left/right weighted alignment.", "status": "closed", "priority": 3, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-30T11:54:15Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-30T11:56:36Z", "started_at": "2026-04-30T11:54:26Z", "closed_at": "2026-04-30T11:56:36Z", "close_reason": "Added shared distributed footer renderer and applied it to search and envs footers", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-7q6", "title": "Align TUI footer right actions", "description": "Use a split footer layout so the right-most footer actions render flush right instead of relying on spacer text.", "status": "closed", "priority": 3, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-30T10:57:00Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-30T10:58:24Z", "started_at": "2026-04-30T10:57:22Z", "closed_at": "2026-04-30T10:58:24Z", "close_reason": "Updated envs footer to split left actions from right-aligned help/back actions using a horizontal layout", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-cpf", "title": "TUI envs editor supports multi-entry envs", "description": "The envs edit flow currently supports single-entry env labels and returns an error for labels with multiple EnvEntry rows. Extend the editor state to hold multiple entry rows with add/remove controls so existing multi-entry envs can be edited in place.", "status": "closed", "priority": 3, "issue_type": "feature", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-30T10:45:06Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-30T23:23:27Z", "started_at": "2026-04-30T23:23:04Z", "closed_at": "2026-04-30T23:23:27Z", "close_reason": "Superseded by hm-6qj envs revamp, which redesigns the editor with full multi-entry/glob support from the ground up.", "dependencies": [{"issue_id": "hm-cpf", "depends_on_id": "hm-h9m", "type": "discovered-from", "created_at": "2026-04-30T03:45:06Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-h9m", "title": "TUI envs tab: edit existing env with live capture preview", "description": "Follow-up to hm-zzj. v1 ships read + delete; this adds `e` to edit an existing env's entries.\n\n## UX\n\n- `e` opens the same editor as the create flow (file separately as hm-zzj-followup-create) prefilled with the selected env's current entries.\n- Right pane continues to show the live `env_resolver::resolve` output as the user edits, so capture-ref expansions (`/$1/postgres-url` against actual store secrets) update in real time.\n- Save calls `envs_mut::upsert` (which is upsert semantics \u2014 replaces existing rows).\n\n## Notes\n\n- Depends on the create flow (shares the editor widget). Build the editor once, reuse it.\n- For wildcard envs, the live preview must enumerate `available_secrets` via `crate::remote::store::list_secrets` to compute capture matches.", "status": "closed", "priority": 3, "issue_type": "feature", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-21T21:58:07Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-30T10:45:15Z", "started_at": "2026-04-30T10:43:17Z", "closed_at": "2026-04-30T10:45:15Z", "close_reason": "Added env edit flow with prefilled editor, Ctrl-S upsert save, live preview while editing, and single-entry edit tests; filed hm-cpf for multi-entry editing", "dependencies": [{"issue_id": "hm-h9m", "depends_on_id": "hm-udz", "type": "blocks", "created_at": "2026-04-21T14:58:17Z", "created_by": "Cooper Maruyama", "metadata": "{}"}], "dependency_count": 1, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-udz", "title": "TUI envs tab: create new env flow", "description": "Follow-up to hm-zzj. v1 of the envs tab shipped read + delete only. This adds the `n` flow to create a new env from inside the tab.\n\n## UX\n\n- `n` opens an inline editor split with two fields:\n 1. Label input \u2014 validated live against `validate_env_label`. Show grammar hints (concrete vs `/*`).\n 2. Entries list \u2014 repeatable rows, each picking a kind (Single | Glob | Alias) and the path/key fields.\n- Save (`Ctrl+S` or `Enter` on a \"Save\" button) calls `envs_mut::upsert(label, entries, scope_hint, cwd)`; on success, reload and select the new label. On error, toast the message and keep the editor open.\n- `Esc` cancels with a confirmation if the form is dirty.\n\n## Wiring\n\n- Route through the same `View::Envs` slot or add a sub-modal \u2014 your call when implementing.\n- Reuse the keymap binding `keymap.envs` already defined; add a sub-binding under it for `n`.", "status": "closed", "priority": 3, "issue_type": "feature", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-21T21:58:07Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-30T10:41:14Z", "started_at": "2026-04-30T10:37:58Z", "closed_at": "2026-04-30T10:41:14Z", "close_reason": "Added TUI envs new-env flow with inline editor, live validation, save via envs_mut::upsert, toasts, and tests", "dependency_count": 0, "dependent_count": 1, "comment_count": 0} -{"_type": "issue", "id": "hm-79o", "title": "Docs: update README for search-as-root TUI flow", "description": "README still references the dashboard-root flow. Update screenshots/gifs to match search-as-root, refresh the key-binding table to list current shortcuts (Ctrl+N new, Ctrl+S store, Ctrl+Y copy, ? help, Esc quit, e edit, d delete with y/n confirm).", "status": "closed", "priority": 3, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-15T06:53:34Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-15T07:01:04Z", "closed_at": "2026-04-15T07:01:04Z", "close_reason": "Merged via parallel worktree agents; cargo test --bin himitsu tui::harness:: \u2192 3 passed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} -{"_type": "issue", "id": "hm-6ia", "title": "CI: run vhs against demo tapes to catch TUI flow regressions", "description": "Add a GitHub Actions job that installs vhs and runs the us-008..us-013 tapes (non-interactive, no gif diffing needed \u2014 just exit code) so broken tapes fail CI before they land. Cache cargo build, reuse release binary.", "status": "closed", "priority": 3, "issue_type": "task", "assignee": "Cooper Maruyama", "owner": "demo@himitsu.dev", "created_at": "2026-04-15T06:53:33Z", "created_by": "Cooper Maruyama", "updated_at": "2026-04-15T07:01:04Z", "closed_at": "2026-04-15T07:01:04Z", "close_reason": "Merged via parallel worktree agents; cargo test --bin himitsu tui::harness:: \u2192 3 passed", "dependency_count": 0, "dependent_count": 0, "comment_count": 0} +{"_type":"issue","id":"hm-lgt","title":"on add new secret form, given i have an existing secret 'foo/bar/baz', and i type 'fo', i should see gray placeholder text inline autcompleting the first match. this is merely to help the user identify if they are adding to the right group and to avoid collisions","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-10T09:09:27Z","created_by":"Cooper Maruyama","updated_at":"2026-05-10T10:28:31Z","started_at":"2026-05-10T09:23:21Z","closed_at":"2026-05-10T10:28:31Z","close_reason":"Implemented and verified with cargo fmt --all -- --check, cargo test --workspace, and cargo clippy --workspace --all-targets -- -D warnings","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-1dh","title":"envs view 'e' key returns 'not yet supported' for multi-entry / tag-selector envs","description":"`tui/views/envs.rs:1575` advertises `('e', 'edit selected env (form mode)')` in help with no qualifier. Pressing `e` on any env with \u003e1 entry or any `tag:` selector returns `Err('edit not yet supported for multi-entry envs (...)')` (envs.rs:154-157) or `Err('edit not yet supported for tag: selectors — use the DSL editor')` (envs.rs:191-193) — surfaced as a CreateFailed toast. Fix: either qualify the help text to 'edit single-entry env (use DSL editor for the rest)' or implement form-mode editing for the missing cases.","status":"closed","priority":2,"issue_type":"bug","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-10T09:07:19Z","created_by":"Cooper Maruyama","updated_at":"2026-05-10T10:28:31Z","started_at":"2026-05-10T09:23:26Z","closed_at":"2026-05-10T10:28:31Z","close_reason":"Implemented and verified with cargo fmt --all -- --check, cargo test --workspace, and cargo clippy --workspace --all-targets -- -D warnings","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-avf","title":"hidden share/inbox stubs pollute git history with FAILED: commits","description":"`cli/share.rs:31` and `cli/inbox.rs:35` unconditionally return `HimitsuError::NotSupported('...not yet implemented (planned for Phase 4)...')`. They're `#[command(hide=true)]` so they don't appear in --help, but the dispatcher includes them in `needs_store` (cli/mod.rs:442), `command_uses_explicit_path_store` (cli/mod.rs:675), AND `mutation_message` (cli/mod.rs:636-637). Net effect: invoking `himitsu share send …` runs full store-resolution + auto-pull, errors, and writes a `himitsu: FAILED: share: not supported` commit to the store's git history. Fix: remove Share/Inbox from `needs_store`, `command_uses_explicit_path_store`, and `mutation_message` until the real implementation lands.","status":"closed","priority":2,"issue_type":"bug","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-10T09:07:19Z","created_by":"Cooper Maruyama","updated_at":"2026-05-10T10:28:31Z","started_at":"2026-05-10T09:23:26Z","closed_at":"2026-05-10T10:28:31Z","close_reason":"Implemented and verified with cargo fmt --all -- --check, cargo test --workspace, and cargo clippy --workspace --all-targets -- -D warnings","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-3rc","title":"TUI autocomplete popup never auto-opens — docstring lies about 'auto-open on suggestions'","description":"`tui/widgets/secret_ref_autocomplete.rs:49-58` docstring says 'The popup auto-opens when there is at least one suggestion to show'. Reality: `recompute()` (lines 153-165) only ever sets `self.open = false`. The only way to surface suggestions is the hidden `Ctrl+Space` chord at `search.rs:257`. CLI-side `himitsu search` correctly prints 'did you mean' on zero-hit queries (`cli/search.rs:236-243`); only the TUI is silent. Fix: in `recompute()`, set `self.open = !self.suggestions.is_empty() \u0026\u0026 !self.query.is_empty()` (or fix the docstring to say the user must call `set_open(true)`).","status":"closed","priority":2,"issue_type":"bug","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-10T09:07:03Z","created_by":"Cooper Maruyama","updated_at":"2026-05-10T10:28:31Z","started_at":"2026-05-10T09:23:21Z","closed_at":"2026-05-10T10:28:31Z","close_reason":"Implemented and verified with cargo fmt --all -- --check, cargo test --workspace, and cargo clippy --workspace --all-targets -- -D warnings","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-c0h","title":"SecretValue.expires_at not enforced on read paths (exec, codegen, export, generate)","description":"`cli/get.rs:92` displays the expiry on `himitsu get` (with traffic-light coloring), but every other read path ignores the field: `cli/exec.rs` (env-var injection), `cli/codegen.rs` (sops mode), `cli/export.rs` (SOPS bundle), `cli/generate.rs` (typed code). Expired secrets flow through into running processes / exported bundles with no warning. Fix: emit a stderr warning when a secret with a past `expires_at` is consumed, and consider an opt-in `--allow-expired` flag on `exec` for a hard refusal.","status":"closed","priority":2,"issue_type":"bug","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-10T09:07:03Z","created_by":"Cooper Maruyama","updated_at":"2026-05-10T10:28:31Z","started_at":"2026-05-10T09:23:26Z","closed_at":"2026-05-10T10:28:31Z","close_reason":"Implemented and verified with cargo fmt --all -- --check, cargo test --workspace, and cargo clippy --workspace --all-targets -- -D warnings","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-b50","title":"global Config.envs is silently dropped — only project envs are read","description":"`config/mod.rs:106` accepts a global `envs:` map in `~/.config/himitsu/config.yaml` and `envs_mut.rs:159` writes `Scope::Global` definitions there, but every CLI consumer reads only `project_cfg.envs`: `cli/exec.rs:96`, `cli/generate.rs:33`, `cli/codegen.rs:139`, `cli/check.rs:117`. A user who sets `envs:` at global scope and runs `himitsu exec myenv -- cmd` outside any project gets 'ref not found' with no hint that the global scope was bypassed. Fix: merge global + project envs in each resolver (project overrides on conflict), or reject global `envs:` at deserialise time with a clear error pointing at the project file.","status":"closed","priority":2,"issue_type":"bug","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-10T09:06:47Z","created_by":"Cooper Maruyama","updated_at":"2026-05-10T10:28:31Z","started_at":"2026-05-10T09:23:26Z","closed_at":"2026-05-10T10:28:31Z","close_reason":"Implemented and verified with cargo fmt --all -- --check, cargo test --workspace, and cargo clippy --workspace --all-targets -- -D warnings","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-zhm","title":"`himitsu rekey --force` flag is parsed but never read","description":"`cli/rekey.rs:10` declares `#[arg(long)] pub force: bool` and `--help` advertises 'Always re-encrypt, even if nothing appears to have changed' — but `run()` discards the value and `rekey_store()` doesn't take it. Either implement the no-op-detection it promises, or rewrite the help text to say '(reserved; currently always re-encrypts)' so the flag isn't a lie.","status":"closed","priority":2,"issue_type":"bug","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-10T09:06:37Z","created_by":"Cooper Maruyama","updated_at":"2026-05-10T10:28:31Z","started_at":"2026-05-10T09:23:32Z","closed_at":"2026-05-10T10:28:31Z","close_reason":"Implemented and verified with cargo fmt --all -- --check, cargo test --workspace, and cargo clippy --workspace --all-targets -- -D warnings","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-csd","title":"floating description tooltip / row expansion for selected search result","description":"On the search view, the currently selected item should have a floating tooltip containing the whole description. Or, alternatively, the currently selected item should expand to take up multiple rows so that the entire description is always visible.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-10T02:31:59Z","created_by":"Cooper Maruyama","updated_at":"2026-05-10T10:28:31Z","started_at":"2026-05-10T09:23:21Z","closed_at":"2026-05-10T10:28:31Z","close_reason":"Implemented and verified with cargo fmt --all -- --check, cargo test --workspace, and cargo clippy --workspace --all-targets -- -D warnings","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-634","title":"on search view, the currently selected item should have a floating tooltip containing the whole description. or, the currently selected item should expand to take up multiple rows so that the entire description is always visible","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-05-09T01:40:16Z","created_by":"Cooper Maruyama","updated_at":"2026-05-10T09:23:05Z","closed_at":"2026-05-10T09:23:05Z","close_reason":"Duplicate of hm-csd; keeping hm-csd as the canonical search-description expansion task","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-68a","title":"introduce an element that is to be used anywhere where i am typing a reference to a secret. it should autocomplete as i type, showing me the closest match based on levenshtein distance. the same code should be used to also add a 'did you mean X' when himitsu search returns no results","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-07T06:22:32Z","created_by":"Cooper Maruyama","updated_at":"2026-05-10T10:28:31Z","started_at":"2026-05-10T09:23:21Z","closed_at":"2026-05-10T10:28:31Z","close_reason":"Implemented and verified with cargo fmt --all -- --check, cargo test --workspace, and cargo clippy --workspace --all-targets -- -D warnings","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-7ob","title":"Update command palette layout","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-05-07T06:06:18Z","created_by":"Cooper Maruyama","updated_at":"2026-05-07T06:10:27Z","started_at":"2026-05-07T06:06:18Z","closed_at":"2026-05-07T06:10:27Z","close_reason":"Updated command palette layout and copy","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-isi","title":"add support for TUI hints - a small bit of text that can render in subtle text on the bottom left corner, sort of like a floating element","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-05-06T23:21:58Z","created_by":"Cooper Maruyama","updated_at":"2026-05-10T09:23:05Z","closed_at":"2026-05-10T09:23:05Z","close_reason":"Implemented on feat-init-tui-flow and verified by cargo test --workspace","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-3w5","title":"all form fields should have placeholders containing an example","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-05-06T23:20:58Z","created_by":"Cooper Maruyama","updated_at":"2026-05-10T09:23:05Z","closed_at":"2026-05-10T09:23:05Z","close_reason":"Implemented on feat-init-tui-flow and verified by cargo test --workspace","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-3rr","title":"add a submit button to the new secret tui, and add a confirm dialog when clicking esc that lets you save or discard","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-05-06T23:20:25Z","created_by":"Cooper Maruyama","updated_at":"2026-05-10T09:23:06Z","closed_at":"2026-05-10T09:23:06Z","close_reason":"Implemented on feat-init-tui-flow and verified by cargo test --workspace","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-6if","title":"Add tags support for secrets (search + env composition)","description":"## Motivation\n\nToday a secret's only addressable axis is its path. That works for hierarchies (`prod/api/STRIPE_KEY`) but not for crosscutting groupings — \"everything PCI\", \"everything the mobile team owns\", \"rotate-this-quarter\". Users currently fake it with path conventions, which collide with the env-prefix model.\n\nAdd free-form tags to secrets so that:\n- An item can carry many tags (`[\"pci\", \"stripe\", \"mobile\"]`).\n- `himitsu search` and `himitsu ls` can filter by tag.\n- The env DSL can select by tag, so an env like `pci-prod` can be authored as `tag: pci` instead of enumerating paths.\n\n## Data model\n\nTags live on `SecretValue` (the encrypted payload), as `repeated string tags = 9` in `proto/secrets.proto`. They travel with the value, are written/read through the existing age envelope, and stay atomic with the secret they describe.\n\n`SecretEntry.tags` (already declared at `proto/secrets.proto:89`) is repurposed as the manifest projection of the same field — populated by listing code that decrypts to surface metadata, identical to how descriptions are currently surfaced in `cli::search::search_core`.\n\nBackwards compatibility: empty tags is the default; legacy envelopes round-trip through `crypto::secret_value::decode` unchanged.\n\nTag grammar: `[A-Za-z0-9_.-]+`, 1–64 chars, no whitespace, case-sensitive. Validated at write time.\n\n## Authoring surfaces\n\n### CLI\n- `himitsu set \u003cpath\u003e \u003cvalue\u003e --tag \u003ct\u003e --tag \u003ct\u003e ...` — repeatable flag on `cli/set.rs`.\n- `himitsu write \u003cpath\u003e --tag \u003ct\u003e` — same flag wired through `cli/write.rs`.\n- `himitsu tag \u003cpath\u003e add \u003ctag\u003e...` / `rm \u003ctag\u003e...` / `list` — new subcommand at `rust/src/cli/tag.rs`, registered in `cli/mod.rs`. Mutates by decrypt → edit `tags` → re-encrypt → write.\n\n### TUI\n- `tui/views/new_secret.rs`: add a `Tags` step (comma-separated input, validated per char as the user types) between `Description` and `EnvKey`.\n- `tui/views/secret_viewer.rs`: render tags as chips in the metadata pane.\n- `tui/views/search.rs`: render tag chips next to each result row; clicking/pressing a chip refines the search.\n\n## Search \u0026 filter\n\n- `himitsu search \u003cquery\u003e [--tag t]...`: AND-semantics across tag flags; empty query + tags = \"all secrets with these tags\". Tags read off the same `Decoded` struct produced by `search_core` decrypt loop.\n- `himitsu ls --tag t [--tag t]...`: same AND filter, applied after path-prefix walk.\n- Query-string sugar (out of scope for this epic, file as follow-up): `tag:foo bar` syntax inside the freeform query.\n\n## Env DSL — new `Tag` entry\n\nExtend `EnvEntry` (`rust/src/config/mod.rs:191`) with a `Tag(String)` variant. YAML shapes the deserializer accepts (extending `EnvEntry::deserialize` at `mod.rs:215`):\n\n- Inline string with `tag:` prefix:\n ```yaml\n envs:\n pci-prod:\n - tag:pci\n - tag:prod\n ```\n AND-semantics across multiple `tag:` entries inside one env (consistent with CLI `--tag` repetition).\n\n- Map form for explicit alias-rename, parallel to existing alias:\n ```yaml\n envs:\n pci-prod:\n - { tag: pci } # all pci-tagged secrets, key = last path segment\n - { STRIPE: tag:stripe } # alias: env-key STRIPE := the (singular) stripe-tagged secret\n ```\n Map-form must error when more than one secret matches the tag (alias requires a single value).\n\nResolver (`rust/src/config/env_resolver.rs:63`) gains a parallel branch to `resolve_concrete` / `resolve_wildcard`: walk `available_secrets`, decrypt-or-cache to read `tags`, include those that match. Wildcard capture refs (`$1`) are not extended to tag entries in this epic — out of scope.\n\n## Out of scope\n- Query-string `tag:` sugar inside `search \u003cquery\u003e` (file as follow-up after this lands).\n- Sharing/inheriting tags across stores via policy.\n- Tag autocomplete in the TUI tag input (nice-to-have follow-up).\n- Capture-ref interpolation inside `Tag` env entries.\n- Renaming/merging tags as a first-class operation (do it manually via `tag rm` + `tag add`).\n\n## Success criteria\n- `himitsu set foo --tag a --tag b` round-trips: `himitsu get foo` shows tags in the metadata block, `himitsu tag foo list` prints `a\\nb`.\n- `himitsu search --tag a` returns only secrets carrying tag `a`; same for `ls --tag a`.\n- `himitsu codegen \u003cenv\u003e` against an env with `- tag:a` produces a SOPS bundle whose keys are exactly the secrets tagged `a`.\n- TUI new-secret form accepts comma-separated tags; secret-viewer renders them; search rows show tag chips.\n- All existing tests pass; new tests cover: tag validation, `EnvEntry` (de)serialization round-trip for both string and map forms, resolver behavior with tag entries, `search`/`ls` filter logic.\n- Legacy secrets (no tags field) decode unchanged.\n","status":"closed","priority":2,"issue_type":"epic","assignee":"Cooper Maruyama","owner":"noreply@anthropic.com","created_at":"2026-05-05T12:14:38Z","created_by":"Claude","updated_at":"2026-05-10T10:28:31Z","started_at":"2026-05-10T09:23:38Z","closed_at":"2026-05-10T10:28:31Z","close_reason":"Implemented and verified with cargo fmt --all -- --check, cargo test --workspace, and cargo clippy --workspace --all-targets -- -D warnings","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-scm","title":"instead of adding a row and indenting items that have a common prefix, add a key to fold/unfold by 1 level, get rid of the extra row, and use a sutble accent to show when folded/unfolded","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-04T12:25:10Z","created_by":"Cooper Maruyama","updated_at":"2026-05-05T03:57:37Z","started_at":"2026-05-04T12:25:35Z","closed_at":"2026-05-05T03:57:37Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-bs1","title":"footer items are not spaced evenly - right-most item should be right-aligned","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-04T12:12:52Z","created_by":"Cooper Maruyama","updated_at":"2026-05-04T12:14:30Z","started_at":"2026-05-04T12:13:19Z","closed_at":"2026-05-04T12:14:30Z","close_reason":"render_distributed_footer now anchors first item Left and last item Right so the footer row reads as evenly distributed across the full width","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-dsa","title":"ctrl+P command menu should contain all valid commands from the stateless CLI","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-04T12:12:25Z","created_by":"Cooper Maruyama","updated_at":"2026-05-05T03:57:37Z","started_at":"2026-05-04T12:25:22Z","closed_at":"2026-05-05T03:57:37Z","close_reason":"Ctrl+P palette now lists every visible top-level CLI command. Wired commands (new/sync/rekey/join/add-remote/switch-store/envs/help/quit/toggle-store-column) keep their behavior. New entries (recipient ls/add/rm/show, remote list/remove/default, context show/set/clear, generate, export, check, docs, import, git) emit an info toast naming the equivalent CLI invocation; full TUI forms can be wired per-command as follow-up.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-8jm","title":"fix himitsu sync failing in the current state","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-04T12:09:41Z","created_by":"Cooper Maruyama","updated_at":"2026-05-04T12:17:24Z","started_at":"2026-05-04T12:11:24Z","closed_at":"2026-05-04T12:17:24Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-mw9","title":"add any compatible commands from the stateless CLI to the command picker in the TUI","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-04T01:15:29Z","created_by":"Cooper Maruyama","updated_at":"2026-05-04T01:18:27Z","started_at":"2026-05-04T01:15:44Z","closed_at":"2026-05-04T01:18:27Z","close_reason":"Added sync, rekey, and join commands to the TUI command palette","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-1wl","title":"himitsu import should convert SOME_KEY to some-key. it should also allow me to do a simple glob filter like foo/* and only import matches, and then it should also let me (unless it does already) set a prefix like --to some/target which would create some/target/{foo,bar,...} or --to github:org/repo#foo would put them in the foo directory. also, --to foo/bar should be equivalent to --to foo/bar/ (the trailing slash is ignored)","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-04T01:01:12Z","created_by":"Cooper Maruyama","updated_at":"2026-05-04T01:07:04Z","started_at":"2026-05-04T01:01:36Z","closed_at":"2026-05-04T01:07:04Z","close_reason":"Implemented key normalization (SOME_KEY→some-key), --filter glob, --to prefix, and --keep-names opt-out","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-u0v","title":"auto-detect if recipient list doesn't contain own age key","description":"## Context\n in that case, the sync status should glow orange and tell you to run 'himitsu join' which would simply add own public key to the stores recipient list, commit, and push. github actions should then rekey in a self-serve way if it is configured with a key in secrets. adding this key to secrets is also something that himitsu can do for you automatically. this task can be seen as idempotently ensuring those 3 conditions are met","notes":"Scope per description: idempotently ensure (1) own pubkey is in the store's recipient list, (2) GitHub Actions has a configured age key in repo secrets, (3) auto-rekey via Actions is wired up. Surface state via TUI store-health glow + 'himitsu join' command that performs (1)-(3). Significant product/UX design — defer until owner aligns on join command shape and Actions self-serve flow.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-03T21:56:09Z","created_by":"Cooper Maruyama","updated_at":"2026-05-03T22:12:52Z","started_at":"2026-05-03T22:06:52Z","closed_at":"2026-05-03T22:12:52Z","close_reason":"Implemented himitsu join command and TUI NotRecipient health indicator","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-bqk","title":"himitsu isn't automatically running init when user has no store","notes":"Investigation 2026-05-03: tui::run (rust/src/tui/mod.rs:33) auto-launches the wizard only on missing key (~/.local/share/himitsu/key). Gap: a user with a key but no resolved store gets an empty dashboard. Fix would be to also trigger run_init_flow when ctx.store.as_os_str().is_empty(). Risk: run_init_flow currently calls run() recursively after the wizard completes (mod.rs:126); init can complete-and-Success without creating a store if --name/--project are not used in the wizard, which would create an infinite loop. Either (a) require --name in the wizard's success path, (b) only re-enter run() when wizard reports a store was created, or (c) return Ok(()) from run() when store is still empty post-wizard. Needs interactive testing.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-03T21:52:55Z","created_by":"Cooper Maruyama","updated_at":"2026-05-03T22:07:19Z","started_at":"2026-05-03T22:05:14Z","closed_at":"2026-05-03T22:07:19Z","close_reason":"TUI now launches init flow when key exists but no store resolves, with post-wizard no-store guard.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-4y5","title":"Cross-repo ref: prompt to clone missing referenced store","description":"When a qualified cross-repo reference (github:org/repo#path) hits a store that isn't checked out locally, prompt for confirmation before cloning. Interactive: y/N prompt (default no). Non-interactive (no TTY): error with hint to run remote add. Add --yes to bypass confirmation for scripted use. TUI: small modal.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-01T10:55:51Z","created_by":"Cooper Maruyama","updated_at":"2026-05-01T10:57:55Z","started_at":"2026-05-01T10:56:38Z","closed_at":"2026-05-01T10:57:55Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-w6c","title":"Cross-repo ref: prefer github:org/repo#path syntax (accept legacy / form)","description":"Switch the canonical cross-repo reference syntax from github:org/repo/path/to/secret to github:org/repo#path/to/secret to disambiguate the slug from the path. Parser must accept both forms; the # form is the canonical one (used in docs, error messages, codegen). The slash form continues to parse as a fallback.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-01T10:55:51Z","created_by":"Cooper Maruyama","updated_at":"2026-05-01T10:57:55Z","started_at":"2026-05-01T10:55:55Z","closed_at":"2026-05-01T10:57:55Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-k4c","title":"init should have placeholder for config set to project if in a git repo, unless the user has not configured a global store yet","description":"In the init wizard's remote-store step, when inside a git repo (or parent is), default the placeholder to the current repo's origin slug and write to \u003cgit_root\u003e/himitsu.yaml as project-scoped config. Always run the global step first (skipped if already configured), labeled 'configure global store', then run a separate 'configure project' step (skipped if not in a git repo).","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-05-01T10:29:47Z","created_by":"Cooper Maruyama","updated_at":"2026-05-01T10:42:26Z","closed_at":"2026-05-01T10:42:26Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-anm","title":"Expand README sync and store health docs","description":"Document sync behavior, auto-commit/push, no-remote and not-pushed warnings, --no-push, auto_pull, and TUI store health states.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-01T00:51:23Z","created_by":"Cooper Maruyama","updated_at":"2026-05-03T22:01:09Z","started_at":"2026-05-03T21:59:14Z","closed_at":"2026-05-03T22:01:09Z","close_reason":"Updated README: TUI-first quick start, search no-op --refresh wording, expanded import/export tool requirements (op, sops), new Configuration section (theme/keys/HIMITSU_TUI_THEME/auto_pull), new Sync \u0026 Store Health section (auto-commit/push, --no-push, store health states), new Demo \u0026 Recordings section (vhs regen, demo-vhs.gif vs tui-us-* roles, CI scratch behavior)","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-1lt","title":"Make README quick start TUI-first","description":"Revise README Quick Start so the primary path launches the TUI first, then presents CLI commands as scripting/automation alternatives.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-01T00:51:22Z","created_by":"Cooper Maruyama","updated_at":"2026-05-03T22:01:09Z","started_at":"2026-05-03T21:59:15Z","closed_at":"2026-05-03T22:01:09Z","close_reason":"Updated README: TUI-first quick start, search no-op --refresh wording, expanded import/export tool requirements (op, sops), new Configuration section (theme/keys/HIMITSU_TUI_THEME/auto_pull), new Sync \u0026 Store Health section (auto-commit/push, --no-push, store health states), new Demo \u0026 Recordings section (vhs regen, demo-vhs.gif vs tui-us-* roles, CI scratch behavior)","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-qku","title":"Document TUI theme and key configuration","description":"Add README documentation for global config TUI settings, including tui.theme, tui.keys, accepted built-in themes, and HIMITSU_TUI_THEME override.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-01T00:51:22Z","created_by":"Cooper Maruyama","updated_at":"2026-05-03T22:01:09Z","started_at":"2026-05-03T21:59:15Z","closed_at":"2026-05-03T22:01:09Z","close_reason":"Updated README: TUI-first quick start, search no-op --refresh wording, expanded import/export tool requirements (op, sops), new Configuration section (theme/keys/HIMITSU_TUI_THEME/auto_pull), new Sync \u0026 Store Health section (auto-commit/push, --no-push, store health states), new Demo \u0026 Recordings section (vhs regen, demo-vhs.gif vs tui-us-* roles, CI scratch behavior)","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-o89","title":"Fix README search documentation","description":"Update README search feature and command docs to reflect current behavior: search reads store files directly and --refresh is retained as a no-op compatibility flag, not a SQLite index rebuild.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-01T00:51:21Z","created_by":"Cooper Maruyama","updated_at":"2026-05-03T22:01:09Z","started_at":"2026-05-03T21:59:16Z","closed_at":"2026-05-03T22:01:09Z","close_reason":"Updated README: TUI-first quick start, search no-op --refresh wording, expanded import/export tool requirements (op, sops), new Configuration section (theme/keys/HIMITSU_TUI_THEME/auto_pull), new Sync \u0026 Store Health section (auto-commit/push, --no-push, store health states), new Demo \u0026 Recordings section (vhs regen, demo-vhs.gif vs tui-us-* roles, CI scratch behavior)","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-1b7","title":"Review README and stabilize demo sync state","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-05-01T00:37:30Z","created_by":"Cooper Maruyama","updated_at":"2026-05-01T00:40:56Z","started_at":"2026-05-01T00:37:37Z","closed_at":"2026-05-01T00:40:56Z","close_reason":"Reviewed README and added demo sync-state override","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-2f9","title":"Refresh VHS demo tapes","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-04-30T23:45:18Z","created_by":"Cooper Maruyama","updated_at":"2026-05-01T00:03:22Z","started_at":"2026-04-30T23:45:23Z","closed_at":"2026-05-01T00:03:22Z","close_reason":"Refreshed VHS demo as sourced pure TUI tape and validated render","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-wqb","title":"allow editing name of items","description":"In the himitsu TUI, pressing Enter on an item opens an editor that lets you edit every field EXCEPT the item's name. Make the name field editable too.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-30T23:19:04Z","created_by":"Cooper Maruyama","updated_at":"2026-05-10T09:23:06Z","started_at":"2026-04-30T23:23:03Z","closed_at":"2026-05-10T09:23:06Z","close_reason":"Implemented on feat-init-tui-flow and verified by cargo test --workspace","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-6qj","title":"revamp envs feature.","description":"## Context\n envs should ultimately be stored like this:\n# config.yaml\n...\nenvs:\n my-env:\n - SOME_KEY: some-exact-match # optionally override env key\n - other-exact-match # uses env key set on item or fallsback to upper(replace(name, -,_)) (forward slash replaced with __)\n - arbitrary-glob/*\nmy-env-{dev,prod,stg}:\n - SOME_KEY: ref-requires-{} # requires specifying where to replace\n - some-group/item-name-{} # using the {} is not required if you have a better format\n\n\nTherefore, the env editor should have an initial browser where i can select an existing env or add a new one, then take me into a 2-panel layout where I am editing the left side as if it was hx and the right pane shows me a preview of what env would be generated based on matches and/or any errors. there should be fuzzy find support and some auto-complete like feature that lets me discover the right labels as im editing","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-30T23:16:44Z","created_by":"Cooper Maruyama","updated_at":"2026-05-10T10:28:31Z","started_at":"2026-04-30T23:23:04Z","closed_at":"2026-05-10T10:28:31Z","close_reason":"Implemented and verified with cargo fmt --all -- --check, cargo test --workspace, and cargo clippy --workspace --all-targets -- -D warnings","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-5l4","title":"Highlight self in recipient ls","description":"Update himitsu recipient ls output so recipient rows whose full age public key matches the current user's own public key are highlighted in TTY output without changing non-TTY/plain output.","status":"closed","priority":2,"issue_type":"feature","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-30T12:26:21Z","created_by":"Cooper Maruyama","updated_at":"2026-04-30T12:28:59Z","started_at":"2026-04-30T12:26:43Z","closed_at":"2026-04-30T12:28:59Z","close_reason":"Highlighted recipient ls rows whose full public key matches the local self public key in TTY output, preserving plain non-TTY output","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-cak","title":"Fix search footer alignment and centering","description":"Apply split-layout footer alignment to the search view and correct small-screen search canvas centering.","status":"closed","priority":2,"issue_type":"bug","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-30T11:01:04Z","created_by":"Cooper Maruyama","updated_at":"2026-04-30T11:02:44Z","started_at":"2026-04-30T11:01:33Z","closed_at":"2026-04-30T11:02:44Z","close_reason":"Aligned search footer right actions with split layout and replaced Layout-based canvas centering with deterministic centered Rect math","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-774","title":"all screens should be the same size as the search view - apply whatever constraints the search view have globally","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-30T04:01:29Z","created_by":"Cooper Maruyama","updated_at":"2026-04-30T10:35:08Z","started_at":"2026-04-30T10:29:32Z","closed_at":"2026-04-30T10:35:08Z","close_reason":"Added shared standard_canvas margin/max-size helper and applied search view constraints across TUI screens and overlays","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-w6y","title":"Review and prune stale tests","description":"Review the current test suite and remove tests that cover deprecated behavior, stale expectations, or backwards-compatibility-only behavior no longer relevant to the Rust rewrite.","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-04-30T03:52:16Z","created_by":"Cooper Maruyama","updated_at":"2026-04-30T04:03:23Z","started_at":"2026-04-30T03:52:27Z","closed_at":"2026-04-30T04:03:23Z","close_reason":"Pruned stale/deprecated/backwards-compat test coverage and verified test suite passes","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-aqi","title":"Submodule-aware commit/push in dispatcher","description":"Enable single-store + git-submodule-for-includes workflow. Previously, himitsu set to a path inside a submodule would strand the file: submodule dirty, parent clean, nothing pushed. Dispatcher now commits submodules first, pushes them first, holds back parent push if any submodule push fails. auto_pull gains --recurse-submodules. Safe detached-HEAD auto-checkout only when HEAD matches origin default-branch tip.","status":"closed","priority":2,"issue_type":"feature","owner":"demo@himitsu.dev","created_at":"2026-04-24T09:18:44Z","created_by":"Cooper Maruyama","updated_at":"2026-04-24T09:19:01Z","closed_at":"2026-04-24T09:19:01Z","close_reason":"Landed: list_submodules/has_unpushed_commits/ensure_on_branch helpers in git.rs; Context::commit walks dirty submodules then parent, Context::push submodules-first with hold-back on failure, pull_if_remote recurses. 4 new tests covering round-trip with real local bare remotes. 460 tests green.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-fy3","title":"himitsu git status should never have unstaged / working changes. every action should commit, and make the git history be essentially an append-only log","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-21T22:13:42Z","created_by":"Cooper Maruyama","updated_at":"2026-04-21T22:40:35Z","closed_at":"2026-04-21T22:40:35Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-ic2","title":"himitsu codegen \u003cenv\u003e emits \u003cenv\u003e.sops.yaml","description":"Extend `himitsu codegen` (rust/src/cli/codegen.rs) to accept an env label and emit a valid SOPS file at `\u003cenv\u003e.sops.yaml`. Today codegen's `--env` flag is already threaded through (see rust/src/cli/codegen.rs:55-56) for language bindings; this task adds a new output mode where the env itself is the target.\n\n## CLI surface\n\n- `himitsu codegen \u003cenv\u003e` — shorthand for `--lang sops --env \u003cenv\u003e --output \u003cenv\u003e.sops.yaml`.\n- `himitsu codegen \u003cenv\u003e --output path.yaml` — override output path.\n- Accepts concrete labels (`foo/dev`) and wildcards (`foo/*`); wildcard form emits one file per matched child (`foo-dev.sops.yaml`, `foo-prod.sops.yaml`), or a single merged file if `--merge` is given (decide in review).\n\n## Output format\n\n- Top-level keys come from the resolved env entries (hm-qjz), each mapped to its decrypted secret value.\n- File is encrypted via `sops` rules: either the project's existing `.sops.yaml` rules file or an inline `creation_rules` block derived from himitsu recipients (TBD — call out in design doc).\n- Generated file contains the same `AUTO-GENERATED` header banner codegen already emits (rust/src/cli/codegen.rs:537).\n\n## Acceptance\n\n- `himitsu codegen foo/dev` produces `foo-dev.sops.yaml` that `sops -d` can decrypt with the configured recipients.\n- Wildcard case produces the expected file set.\n- Errors clearly when the env label is unknown or when captures fail to resolve.","notes":"## Integration decision (2026-04-21)\n\n**Shell out to the `sops` CLI.** Generate plaintext YAML from the resolved env tree, write it to the output path, then invoke `sops --encrypt --in-place \u003cpath\u003e`. Key/recipient resolution is delegated to the user's existing `.sops.yaml` rules file.\n\n- Requires `sops` on PATH — detect and emit a helpful error if missing.\n- No new crypto crate dependency.\n- Existing `AUTO-GENERATED` header banner applies to the plaintext emission before encryption.","status":"closed","priority":2,"issue_type":"feature","owner":"demo@himitsu.dev","created_at":"2026-04-21T20:39:24Z","created_by":"Cooper Maruyama","updated_at":"2026-04-21T21:32:38Z","closed_at":"2026-04-21T21:32:38Z","close_reason":"Landed: codegen \u003cenv\u003e positional triggers sops path. Resolver-\u003edecrypt-\u003eYAML-\u003esops encrypt-in-place. Missing-sops error has install hint. 6 new tests green, 1 ignored smoke.","dependencies":[{"issue_id":"hm-ic2","depends_on_id":"hm-20e","type":"blocks","created_at":"2026-04-21T13:39:42Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"hm-ic2","depends_on_id":"hm-r2e","type":"parent","created_at":"2026-04-21T13:39:45Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-20e","title":"Env resolver: expand wildcards and capture references","description":"Central pure-function resolver that both the TUI preview and `himitsu codegen \u003cenv\u003e` depend on. Given the full env map + a target label, return the concrete resolved entries (key → secret path, no wildcards, no captures remaining).\n\n## Behavior\n\n- Input: `\u0026Config.envs`, target label (e.g. `foo/*` or `foo/dev`).\n- Resolution:\n 1. If label is concrete, return its entries verbatim (Aliases already resolved to path refs).\n 2. If label ends in `/*`, enumerate sibling envs whose labels match `foo/\u003csegment\u003e` (or deeper — TBD in schema design), and for each child, substitute captures into the glob's entries (e.g. `\\$1` → the matched segment).\n 3. Merge overlapping keys: concrete child entries win over wildcard-inherited ones.\n- Output: `Vec\u003cResolvedEntry { key: String, secret_path: String }\u003e` plus diagnostics for unresolved captures or missing targets.\n\n## Why a dedicated resolver\n\nCodegen (hm-nm9) and the TUI preview (hm-kli) both need identical semantics. Putting it in one place means users never see a mismatch between TUI preview and generated SOPS output.\n\n## Acceptance\n\n- Table-driven tests for: concrete label, wildcard with 0/1/N children, nested wildcards (if allowed by schema), captures in Alias paths, capture index out of range.\n- Resolver returns deterministic order (sort by key) so generated SOPS files are diff-friendly.","notes":"## Output shape correction (2026-04-21)\n\nThe resolver returns a **nested tree**, not a flat `Vec\u003cResolvedEntry\u003e`. Shape (proposed):\n\n```rust\nenum EnvNode {\n Leaf { secret_path: String },\n Branch(BTreeMap\u003cString, EnvNode\u003e),\n}\n```\n\nReasoning: `foo/*` expansion over children `foo/bar` and `foo/bar/baz` must produce a single nested structure, not a flat list and not multiple resolver invocations. Captures (`\\$1`) still substitute path segments, but the output is always a tree rooted at the queried label.\n\nGlob entries reference secret variables only — never other env groups. The resolver does not follow cross-env references.\n\nDeterminism: `BTreeMap` gives sorted keys → diff-friendly downstream codegen.","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-04-21T20:39:15Z","created_by":"Cooper Maruyama","updated_at":"2026-04-21T21:12:58Z","closed_at":"2026-04-21T21:12:58Z","close_reason":"Wave 2 landed in commit. Resolver returns deterministic EnvNode trees for concrete + wildcard labels. Cache mirrors envs into SQLite with scope isolation, SHA-256 staleness, atomic refresh. 18 new tests green.","dependencies":[{"issue_id":"hm-20e","depends_on_id":"hm-5v4","type":"blocks","created_at":"2026-04-21T13:39:39Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"hm-20e","depends_on_id":"hm-r2e","type":"parent","created_at":"2026-04-21T13:39:44Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"hm-zzj","title":"TUI Envs tab: list, create, edit, delete presets","description":"Add a dedicated Envs tab alongside the existing TUI views (see rust/src/tui/views/). It becomes the primary UX for authoring preset environments.\n\n## UX sketch\n\n- Left pane: tree of env labels grouped by scope (project first, then global). `foo`, `foo/bar`, `foo/*` render as a hierarchy.\n- Right pane: the selected env's resolved entries — Singles, Aliases, and the expanded form of any Glob (so users see what `foo/*` actually produces).\n- Keymap reuses the project's existing conventions (rust/src/tui/keymap.rs): `n` new env, `e` edit, `d` delete, `/` search, `Enter` drill in.\n- Inline editor for entry kinds (Single | Glob | Alias) with a live preview column showing capture-group expansions.\n\n## Wiring\n\n- Reads come from the SQLite cache (hm-8ht).\n- Writes go through the mutation layer (hm-d1m); optimistic UI with toast rollback on failure.\n- Scope indicator visible at all times; creation defaults to project scope inside a git repo, global otherwise.\n\n## Acceptance\n\n- Can create `foo`, `foo/bar`, and `foo/*` entries end-to-end from the TUI.\n- Editing an Alias with a capture ref renders the live expansion for each matched child env.\n- Deleting an env removes it from YAML + cache on disk.","notes":"## Scope reduction (2026-04-21)\n\n**First slice: read-only + delete.** Creation and inline editing are deferred to follow-up issues (to be filed when this one lands).\n\nIncluded in v1:\n- Tree/list view of envs grouped by scope (project/global).\n- Right pane shows resolved entries (Singles, Aliases, expanded Globs via hm-20e).\n- `d` deletes the selected env (with confirmation), routed through the mutation API (hm-d1m).\n- `/` search, scope indicator, navigation.\n\nDeferred to follow-ups:\n- `n` new env flow with inline editor.\n- `e` edit existing entries.\n- Live capture-reference preview editor.\n\nRationale: smallest slice that validates the cache + resolver + mutation stack end-to-end through real UI. Creation UX deserves its own design pass once read-path is proven.","status":"closed","priority":2,"issue_type":"feature","owner":"demo@himitsu.dev","created_at":"2026-04-21T20:39:06Z","created_by":"Cooper Maruyama","updated_at":"2026-04-21T21:57:31Z","closed_at":"2026-04-21T21:57:31Z","close_reason":"Read+delete TUI tab landed. Epic complete: schema + cache + resolver + mutation + codegen sops + TUI all merged. 5 new view tests; 337 lib tests pass.","dependencies":[{"issue_id":"hm-zzj","depends_on_id":"hm-20e","type":"blocks","created_at":"2026-04-21T13:39:41Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"hm-zzj","depends_on_id":"hm-8ht","type":"blocks","created_at":"2026-04-21T13:39:40Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"hm-zzj","depends_on_id":"hm-d1m","type":"blocks","created_at":"2026-04-21T13:39:40Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"hm-zzj","depends_on_id":"hm-r2e","type":"parent","created_at":"2026-04-21T13:39:45Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":3,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-d1m","title":"Config mutation layer: write envs to project or global scope","description":"Add a mutation API that the TUI and any future CLI subcommands call to create/update/delete env presets. Scope is inferred the same way `codegen` already does it (rust/src/cli/codegen.rs:118-127): walk up for `.himitsu.yaml`, else fall back to global config.\n\n## Requirements\n\n- Single entry point: `envs::upsert(label, entries, scope_hint) -\u003e Result\u003c()\u003e`; sibling `envs::delete(label, scope)`.\n- Preserve YAML formatting/comments where feasible (serde_yaml round-trip is lossy — evaluate `yaml-rust2` or string-level edits if comments matter here).\n- Atomic writes: temp file + rename so a crash mid-write does not corrupt config.\n- After every mutation, refresh the SQLite cache row for that scope.\n- Validation: reject labels that violate the grammar from the schema-design task.\n\n## Acceptance\n\n- Unit tests for project vs global routing, label validation, atomic write behavior.\n- Integration test: mutate via API → reload `Config` from disk → observe change.","notes":"## YAML fidelity decision (2026-04-21)\n\n**Accept lossy round-trip via `serde_yaml`.** Comments and custom formatting in `.himitsu.yaml` / global config will not survive a mutation. This matches the current codebase pattern and keeps the implementation small.\n\n- Document the lossy behavior in the TUI help panel + user-facing docs.\n- Atomic write (temp file + rename) still required to avoid partial writes.","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-04-21T20:38:54Z","created_by":"Cooper Maruyama","updated_at":"2026-04-21T21:23:55Z","closed_at":"2026-04-21T21:23:55Z","close_reason":"Landed: upsert/delete/read API with ScopeHint inference, atomic writes, validation, cache refresh. Config gained first-class envs field. 11 new tests green.","dependencies":[{"issue_id":"hm-d1m","depends_on_id":"hm-5v4","type":"blocks","created_at":"2026-04-21T13:39:37Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"hm-d1m","depends_on_id":"hm-8ht","type":"blocks","created_at":"2026-04-21T13:39:38Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"hm-d1m","depends_on_id":"hm-r2e","type":"parent","created_at":"2026-04-21T13:39:44Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":2,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"hm-8ht","title":"SQLite cache for env presets (project + global scope)","description":"Mirror `envs:` from YAML config into SQLite so the TUI can list/search without re-parsing YAML on every keystroke, and so other subsystems (codegen, check) have a uniform read API.\n\n## Requirements\n\n- Tables (proposed):\n - `envs(id INTEGER PK, label TEXT, scope TEXT CHECK(scope IN ('project','global')), config_path TEXT, mtime INTEGER)`\n - `env_entries(id INTEGER PK, env_id INTEGER REFERENCES envs(id) ON DELETE CASCADE, kind TEXT, value TEXT, alias_key TEXT)`\n- Rebuild strategy: on load, compare config file hash/mtime against cached row; rebuild if stale.\n- Scope handling: project rows keyed by git-root path so switching projects does not pollute.\n- YAML remains source of truth — every write path must rewrite YAML then refresh the cache; never the reverse.\n\n## Non-goals\n\n- No schema migrations framework yet — initial table ships with the feature.\n- No cross-device sync; the cache is purely local.\n\n## Acceptance\n\n- Round-trip test: load YAML → cache → query → matches parsed `Config.envs`.\n- Stale detection: mutating YAML on disk triggers cache rebuild on next read.","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-04-21T20:38:47Z","created_by":"Cooper Maruyama","updated_at":"2026-04-21T21:12:58Z","closed_at":"2026-04-21T21:12:58Z","close_reason":"Wave 2 landed in commit. Resolver returns deterministic EnvNode trees for concrete + wildcard labels. Cache mirrors envs into SQLite with scope isolation, SHA-256 staleness, atomic refresh. 18 new tests green.","dependencies":[{"issue_id":"hm-8ht","depends_on_id":"hm-5v4","type":"blocks","created_at":"2026-04-21T13:39:36Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"hm-8ht","depends_on_id":"hm-r2e","type":"parent","created_at":"2026-04-21T13:39:43Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"hm-5v4","title":"Design env schema: wildcard semantics + capture references","description":"Formalize the env label/entry model before any TUI or codegen work lands. The current `EnvEntry` enum (rust/src/config/mod.rs:134) has `Single`, `Glob`, `Alias` — decide how capture references like `/\\$1/postgres-url` are expressed on-disk and in-memory.\n\n## Questions to resolve\n\n- Label grammar: confirm `foo`, `foo/bar`, `foo/*` are the only legal forms; reject `foo/*/bar`? Or allow mid-path wildcards?\n- Does a `foo/*` env enumerate its children by scanning other env labels in config, or by scanning the secret namespace? (Probably: scan sibling env labels that match `foo/\u003csomething\u003e`.)\n- Capture-ref syntax: `\\$1`, `\\${1}`, `{{1}}`? Which path segments are captured — everything matched by `*`?\n- Do captures only make sense inside `Glob` entries, or also inside `Alias.path`? Likely the latter: `Alias { key: DB_URL, path: \\\"/\\$1/postgres-url\\\" }` under a `foo/*` env.\n- Round-trip serde: new `EnvEntry` variant or reuse existing `Glob`/`Alias` with pattern detection?\n\n## Deliverable\n\nA short design doc (in this issue's `--design`) + updated serde tests in rust/src/config/mod.rs covering:\n- Capture-ref in Alias paths\n- Invalid label rejection\n- Expansion semantics (input env set → resolved concrete entries)","notes":"## Locked constraints (2026-04-21)\n\n- **Reject mid-path wildcards.** Only `foo`, `foo/bar`, `foo/*` are valid. `foo/*/bar`, `*/foo`, and similar are hard errors at config-parse time.\n- **Glob entries reference secret variables only, never other env groups.** Inside a `foo/*` env, entries describe secret paths (optionally with `\\$1` captures) — they do not pull in other env labels.\n- **Wildcards produce nested output, never multiple files.** `foo/*` expanded over concrete children `foo/bar`, `foo/bar/baz` yields a single tree:\n ```\n foo:\n bar:\n baz: \u003cvalue\u003e\n ```\n This mirrors how sops would naturally represent a hierarchical namespace.\n\nThese constraints must be enforced by the label grammar validator and reflected in the resolver's output shape (which is a tree, not a flat list — see hm-20e).","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-21T20:38:39Z","created_by":"Cooper Maruyama","updated_at":"2026-04-21T20:51:37Z","closed_at":"2026-04-21T20:51:37Z","close_reason":"Schema + validator landed: validate_env_label, parse_captures, validate_envs, ProjectConfig::validate. 15 new tests green, full workspace builds clean. Mid-path wildcards, bare-wildcard, bad chars, capture-in-concrete-env, and high-capture-index all rejected. Resolver (hm-20e) and codegen (hm-ic2) can now consume these helpers.","dependencies":[{"issue_id":"hm-5v4","depends_on_id":"hm-r2e","type":"parent","created_at":"2026-04-21T13:39:43Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":0,"dependent_count":3,"comment_count":0} +{"_type":"issue","id":"hm-r2e","title":"Preset environment management (TUI tab + codegen integration)","description":"## Motivation\n\nUsers already declare environment presets in config under `envs:` as a `BTreeMap\u003cString, Vec\u003cEnvEntry\u003e\u003e` with `Single`, `Glob`, and `Alias` variants (see rust/src/config/mod.rs:116, :134). Today this config is only consumable by read paths — there is no UX for authoring, visualizing, or sharing envs, and `himitsu codegen` currently emits language bindings rather than deployable secret bundles.\n\nThis epic adds a first-class preset environment manager that:\n- Surfaces envs as a dedicated tab in the TUI\n- Treats env labels as a path namespace: `foo`, `foo/bar`, `foo/*` are all valid, where `foo/*` is a wildcard shortcut that expands across children\n- Supports capture-group references inside wildcard entries, e.g. `/$1/postgres-url` lets `foo/*` expand into `foo/dev/postgres-url`, `foo/prod/postgres-url`, etc.\n- Mutates the correct config file based on context (project `.himitsu.yaml` vs global config)\n- Mirrors the config into the SQLite cache so reads are fast and the TUI stays responsive\n- Extends `himitsu codegen \u003cenv\u003e` to emit `\u003cenv\u003e.sops.yaml` — a valid SOPS-encrypted secret bundle — so envs become deployable artifacts, not just labels\n\n## Data model\n\nConceptually this is a many-to-many relationship: envs ↔ secrets. In SQLite this is cached as (example, subject to design child):\n\n- `envs(id, label, scope)` — `scope` ∈ {project, global}; `label` stores the full namespaced path (`foo`, `foo/bar`)\n- `env_entries(env_id, kind, value, alias_key, capture_pattern)` — one row per EnvEntry, preserving Single/Glob/Alias semantics\n\nAuthoritative storage stays YAML; SQLite is a rebuilt cache keyed off config file mtime/hash.\n\n## Scope boundary\n\n- Scope (project vs global) is inferred the same way `codegen` already does it — walk up for `.himitsu.yaml`, else global.\n- All mutations are writes to the YAML config plus a cache-refresh; no new storage of truth in SQLite.\n\n## Out of scope\n\n- Changing how secrets themselves are stored/encrypted\n- Re-keying or rotation flows (separate surface)\n- Any non-SOPS codegen output format (typescript/go/etc. stay as-is)\n\n## Success criteria\n\n- TUI has an Envs tab that lists, creates, edits, and deletes env presets with wildcard + alias support\n- Wildcard capture references (`$1`, `$2`, ...) resolve correctly in both TUI preview and codegen\n- `himitsu codegen \u003cenv\u003e` produces a `\u003cenv\u003e.sops.yaml` that round-trips through `sops` without manual editing\n- SQLite cache stays consistent with YAML after every mutation; restart reflects on-disk state","status":"closed","priority":2,"issue_type":"epic","owner":"demo@himitsu.dev","created_at":"2026-04-21T20:38:26Z","created_by":"Cooper Maruyama","updated_at":"2026-04-21T21:57:31Z","closed_at":"2026-04-21T21:57:31Z","close_reason":"Read+delete TUI tab landed. Epic complete: schema + cache + resolver + mutation + codegen sops + TUI all merged. 5 new view tests; 337 lib tests pass.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-vy9","title":"remove reference to legacy 'groups' command - use recipients in a path-based manner instead, e.g. team/person or team/*","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-21T20:26:12Z","created_by":"Cooper Maruyama","updated_at":"2026-04-21T20:34:51Z","closed_at":"2026-04-21T20:34:51Z","close_reason":"removed group references from README and CLI help, updated to path-based recipients","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-czj","title":"TUI details/edit should support arbitrary fields (key/value pairs)","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-18T19:31:36Z","created_by":"Cooper Maruyama","updated_at":"2026-04-18T19:41:28Z","closed_at":"2026-04-18T19:41:28Z","close_reason":"annotations wired through decode/encode, TUI viewer displays them, edit doc accepts custom key:value as annotations","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-tz3","title":"TUI should display status of remote store (if its out of sync/unhealthy)","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-18T15:53:59Z","created_by":"Cooper Maruyama","updated_at":"2026-04-18T16:26:20Z","closed_at":"2026-04-18T16:26:20Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-6mo","title":"get rid of groups. recipients should allow for path-based names which would allow the equivalent of groups by just using foo/*","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-18T15:53:12Z","created_by":"Cooper Maruyama","updated_at":"2026-04-18T16:26:21Z","closed_at":"2026-04-18T16:26:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-6nb","title":"add export command that lets you create SOPS-compatble files, mapping multiple keys to secrets, with support for foo/bar/* which would include all matching secrets with their default key","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-18T15:51:13Z","created_by":"Cooper Maruyama","updated_at":"2026-04-18T16:26:21Z","closed_at":"2026-04-18T16:26:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-jk1","title":"add support for SOPS to import","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-18T15:49:37Z","created_by":"Cooper Maruyama","updated_at":"2026-04-18T16:26:21Z","closed_at":"2026-04-18T16:26:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-far","title":"import command should allow importing the whole vault, including a dry run","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-18T15:49:05Z","created_by":"Cooper Maruyama","updated_at":"2026-04-18T16:26:21Z","closed_at":"2026-04-18T16:26:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-yxb","title":"search should show time ago format for created","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-04-18T15:38:47Z","created_by":"Cooper Maruyama","updated_at":"2026-04-18T15:54:48Z","closed_at":"2026-04-18T15:54:48Z","close_reason":"Implemented in TUI search view: humanized dates via humanize_age() and description column added","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-zzu","title":"search page should show excerpt of description.","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-04-18T15:38:26Z","created_by":"Cooper Maruyama","updated_at":"2026-04-18T15:54:48Z","closed_at":"2026-04-18T15:54:48Z","close_reason":"Implemented in TUI search view: humanized dates via humanize_age() and description column added","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-pmy","title":"every mutation should trigger a git sync to remote if configured","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-18T15:23:14Z","created_by":"Cooper Maruyama","updated_at":"2026-04-18T16:26:21Z","closed_at":"2026-04-18T16:26:21Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-bln","title":"himitsu 'get/read' completions should show available secrets, not files in the current directory","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-15T14:11:07Z","created_by":"Cooper Maruyama","updated_at":"2026-04-17T07:14:55Z","closed_at":"2026-04-17T07:14:55Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-p8g","title":"add import command, start with import from 1password via op cli","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-15T14:09:11Z","created_by":"Cooper Maruyama","updated_at":"2026-04-17T07:14:57Z","closed_at":"2026-04-17T07:14:57Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-t81","title":"use 'n days ago' format for date in search. include description, and put store as last column.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-15T14:08:28Z","created_by":"Cooper Maruyama","updated_at":"2026-04-17T07:14:58Z","closed_at":"2026-04-17T07:14:58Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-f6z","title":"TUI: integration test harness driving App via simulated key events","description":"Build a test harness that constructs App, feeds a sequence of crossterm::event::KeyEvent through the main loop tick, and asserts on rendered buffer state. Enables end-to-end coverage of search→viewer→new-secret flows without spawning a real terminal.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-15T06:53:32Z","created_by":"Cooper Maruyama","updated_at":"2026-04-15T07:01:04Z","closed_at":"2026-04-15T07:01:04Z","close_reason":"Merged via parallel worktree agents; cargo test --bin himitsu tui::harness:: → 3 passed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-3cm","title":"TUI: configurable keybindings via config file","description":"Allow users to remap TUI key bindings through the himitsu config file. Define a KeyMap struct, default to current bindings, let config override by action name (e.g. 'quit', 'new_secret', 'switch_store'). Threaded through App to each view's handle_key.","status":"closed","priority":2,"issue_type":"feature","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-15T06:53:30Z","created_by":"Cooper Maruyama","updated_at":"2026-04-15T07:19:59Z","closed_at":"2026-04-15T07:19:59Z","close_reason":"Merged to main; 231 tests pass (toast + configurable keymap). Conflicts in app.rs/search.rs/secret_viewer.rs resolved by keeping toast-based action returns on top of keymap dispatch","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-o15","title":"TUI: status-line toast for transient feedback","description":"Add a toast/status-line component that renders transient messages (saved, copied, deleted, error) for N seconds then clears. Currently feedback is inline-only in individual views. Should be globally mounted in App so every view can emit a toast.","status":"closed","priority":2,"issue_type":"feature","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-15T06:53:21Z","created_by":"Cooper Maruyama","updated_at":"2026-04-15T07:19:59Z","closed_at":"2026-04-15T07:19:59Z","close_reason":"Merged to main; 231 tests pass (toast + configurable keymap). Conflicts in app.rs/search.rs/secret_viewer.rs resolved by keeping toast-based action returns on top of keymap dispatch","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-64c","title":"record new demo after major tui changes","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-15T02:28:44Z","created_by":"Cooper Maruyama","updated_at":"2026-04-15T02:54:24Z","closed_at":"2026-04-15T02:54:24Z","close_reason":"Rewrote and re-recorded all 6 TUI tapes (us-008..us-013) against search-as-root + new-secret metadata + store picker flow","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-r4i","title":"new secret flow should alow setting metadata, not just edit","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-14T14:23:45Z","created_by":"Cooper Maruyama","updated_at":"2026-04-15T02:46:54Z","closed_at":"2026-04-15T02:46:54Z","close_reason":"metadata fields (description/url/totp/env_key/expires_at) added to new-secret form with tab nav, validation, 13 new tests","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-y6n","title":"provide alternative hotkey for ctrl+s to save, as it is commmonly set in tmux and conflicts","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-14T14:23:19Z","created_by":"Cooper Maruyama","updated_at":"2026-04-14T14:28:32Z","closed_at":"2026-04-14T14:28:32Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-72k","title":"get rid of 'env', its being used as a group which is incorrect. it shouldnt even show in the search tui. env is for codegen which I will spec out later","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-14T14:20:35Z","created_by":"Cooper Maruyama","updated_at":"2026-04-14T14:28:32Z","closed_at":"2026-04-14T14:28:32Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-8z8","title":"search view should have a hotkey for copying to clipboard","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T18:07:09Z","created_by":"Cooper Maruyama","updated_at":"2026-04-14T14:23:23Z","closed_at":"2026-04-14T14:23:23Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-57k","title":"tui should render all stores together, rendering the store as a column","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T18:06:03Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T18:11:43Z","closed_at":"2026-04-13T18:11:43Z","close_reason":"multi-store grouping with per-store header rows","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-rnx","title":"TUI secret viewer should render all decoded metadata","description":"The secret viewer in the TUI only shows path/env/created_at/lastmodified/recipients. It should also render description, url, totp, expires_at, and env_key from the decoded SecretValue so users can see everything they just edited without round-tripping through the editor.","status":"closed","priority":2,"issue_type":"feature","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T18:05:44Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T18:12:51Z","closed_at":"2026-04-13T18:12:51Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-ldq","title":"Add env_key metadata field to SecretValue","description":"Prepare for 'himitsu exec path/* -- cmd' by letting each secret carry a default environment variable name. When unset, callers derive one from the path (typically last segment uppercased). Description field already exists from hm-dvs/hm-de4.","acceptance_criteria":"proto field added; --env-key flag on set; validates as POSIX env var name; surfaced by himitsu get; round-trips through encode/decode","status":"closed","priority":2,"issue_type":"feature","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T17:20:50Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T17:21:05Z","closed_at":"2026-04-13T17:21:05Z","close_reason":"env_key field added end-to-end with validation + tests","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-tcp","title":"columns should have header labels in tui","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T17:17:56Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T18:12:51Z","closed_at":"2026-04-13T18:12:51Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-3ir","title":"the default tui should use a search bar with fuzzy find instead of the two-column navigator","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T04:04:48Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T04:11:38Z","closed_at":"2026-04-13T04:11:38Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-jjc","title":"add fuzzy find to search","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T03:14:50Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:39:11Z","closed_at":"2026-04-13T03:39:11Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-yu4","title":"recipients should allow for adding metadata. adding a recipient should additionally prompt for a description (optional)","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T03:11:02Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:39:11Z","closed_at":"2026-04-13T03:39:11Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-dvs","title":"add a field called totp that can be used to associate a TOTP key for any secret. also add a URL field that can be used to store the website it is associated with","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T03:10:15Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:39:11Z","closed_at":"2026-04-13T03:39:11Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-de4","title":"add a expires_at field to metadata, can be optionally set to remind when the secret is about to expire","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T03:08:26Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:39:11Z","closed_at":"2026-04-13T03:39:11Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-6xd","title":".himitsu/recipients should contain a flat list of recipients. the group should not be determined by folder nesting, but rather by a mapping in .himitsu.yaml - this solves the issue where a recipient cannot belong to multiple groups without duplicating files","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T03:06:39Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:39:11Z","closed_at":"2026-04-13T03:39:11Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-ftf","title":"implement himitsu read/write which are like get/set except they return just the plaintext secret, render no metadata, and are meant to be used in other tools","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T03:04:41Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:39:11Z","closed_at":"2026-04-13T03:39:11Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-381","title":"implement completions","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T03:02:56Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:39:11Z","closed_at":"2026-04-13T03:39:11Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-28r","title":"himitsu search should look similar to gh repo list","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T02:56:40Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:39:11Z","closed_at":"2026-04-13T03:39:11Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-iq6","title":"himitsu tui navigation should look more like a table that shows the path, description, last modified date, and store it belongs to. folders should be sorted first just like in the file explorer in most IDEs","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T02:40:26Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:51:25Z","closed_at":"2026-04-13T03:51:25Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-6vb.6","title":"US-013: Store/env switch from dashboard","description":"Let the user switch the active store (recipients/data dir) without restarting the TUI. Add a store picker (e.g. 's' key) that lists configured stores from config and rebuilds the dashboard against the chosen store. Scope of 'env switch' is unclear — envs are derived from path prefixes, so selection already works; confirm with user whether this bead should also cover a true multi-context switch.","notes":"Deliverable must include a demo artifact for the completed work: both a VHS tape (demo/*.tape → regenerated .gif) and an asciinema .cast recording that walks through the new flow end-to-end. Follow the existing demo/ conventions from the US-001..US-006 series.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T02:14:51Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:12:16Z","closed_at":"2026-04-13T03:12:16Z","close_reason":"Closed","dependencies":[{"issue_id":"hm-6vb.6","depends_on_id":"hm-6vb","type":"parent-child","created_at":"2026-04-12T19:14:50Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-6vb.3","title":"US-011: Drill into secret from dashboard","description":"Pressing Enter on a secret in the dashboard's right-hand list currently does nothing. Wire it to open the SecretViewerView directly, skipping the search detour. Also add j/k/arrow navigation within the secret list (currently only env list navigates).","notes":"Deliverable must include a demo artifact for the completed work: both a VHS tape (demo/*.tape → regenerated .gif) and an asciinema .cast recording that walks through the new flow end-to-end. Follow the existing demo/ conventions from the US-001..US-006 series.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T02:14:40Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:12:15Z","closed_at":"2026-04-13T03:12:15Z","close_reason":"Closed","dependencies":[{"issue_id":"hm-6vb.3","depends_on_id":"hm-6vb","type":"parent-child","created_at":"2026-04-12T19:14:39Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-6vb.4","title":"US-012: Help overlay ('?')","description":"Add a modal help overlay bound to '?' (and maybe F1) that lists all keybindings for the current view. Should be view-aware: dashboard, search, and viewer each show their own bindings. Dismiss with Esc or '?'.","notes":"Deliverable must include a demo artifact for the completed work: both a VHS tape (demo/*.tape → regenerated .gif) and an asciinema .cast recording that walks through the new flow end-to-end. Follow the existing demo/ conventions from the US-001..US-006 series.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T02:14:40Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:12:15Z","closed_at":"2026-04-13T03:12:15Z","close_reason":"Closed","dependencies":[{"issue_id":"hm-6vb.4","depends_on_id":"hm-6vb","type":"parent-child","created_at":"2026-04-12T19:14:39Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-6vb.5","title":"US-008: Create secret from TUI","description":"Add a 'new secret' flow reachable from the dashboard (e.g. 'n' keybinding). Should prompt for env/path and value (value input hidden), then call the same internal path as 'himitsu set' — no subprocess. On success, refresh the dashboard list and select the new entry.","notes":"Deliverable must include a demo artifact for the completed work: both a VHS tape (demo/*.tape → regenerated .gif) and an asciinema .cast recording that walks through the new flow end-to-end. Follow the existing demo/ conventions from the US-001..US-006 series.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T02:14:40Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:12:15Z","closed_at":"2026-04-13T03:12:15Z","close_reason":"Closed","dependencies":[{"issue_id":"hm-6vb.5","depends_on_id":"hm-6vb","type":"parent-child","created_at":"2026-04-12T19:14:39Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-6vb.1","title":"US-009: Delete secret from TUI","description":"Add a delete action in the secret viewer and/or search result list (e.g. 'd' with a confirmation prompt). Wire to the existing store delete path. Refresh the parent view on success and surface errors as a status line.","notes":"Deliverable must include a demo artifact for the completed work: both a VHS tape (demo/*.tape → regenerated .gif) and an asciinema .cast recording that walks through the new flow end-to-end. Follow the existing demo/ conventions from the US-001..US-006 series.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T02:14:39Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:12:15Z","closed_at":"2026-04-13T03:12:15Z","close_reason":"Closed","dependencies":[{"issue_id":"hm-6vb.1","depends_on_id":"hm-6vb","type":"parent-child","created_at":"2026-04-12T19:14:39Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-6vb.2","title":"US-010: Edit secret value from TUI","description":"Currently 'e' on the secret viewer rekeys — rebind rekey and add a real edit flow that decrypts the current value, opens it in an in-TUI editor (or $EDITOR fallback with terminal suspend, matching the init wizard pattern), and re-encrypts on save. Decide with the user whether to preserve 'e' for edit and move rekey to a less prominent key.","notes":"Deliverable must include a demo artifact for the completed work: both a VHS tape (demo/*.tape → regenerated .gif) and an asciinema .cast recording that walks through the new flow end-to-end. Follow the existing demo/ conventions from the US-001..US-006 series.","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T02:14:39Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:12:15Z","closed_at":"2026-04-13T03:12:15Z","close_reason":"Closed","dependencies":[{"issue_id":"hm-6vb.2","depends_on_id":"hm-6vb","type":"parent-child","created_at":"2026-04-12T19:14:39Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-6vb","title":"TUI Phase 2: CRUD + UX polish","description":"Follow-on epic to hm-nck. The Rust/ratatui TUI shipped as a read-only explorer (dashboard → search → viewer). This epic adds the missing CRUD actions (create/edit/delete), first-class navigation from the dashboard, a help overlay, and store/env switching so the TUI stands on its own without shelling out to the CLI for common operations.","status":"closed","priority":2,"issue_type":"epic","owner":"demo@himitsu.dev","created_at":"2026-04-13T02:14:19Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T03:12:16Z","closed_at":"2026-04-13T03:12:16Z","close_reason":"all steps complete","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-c84","title":"US-007: Global navigation and keybindings","description":"Consistent global keybindings: q quits from dashboard (ignored in inputs), Ctrl-C always quits with terminal cleanup, Esc goes back one view, / enters search from dashboard. Footer hint bar on every view. Resize event re-lays out without panic. Event loop uses crossterm::event::poll with sane timeout (no busy spin).","acceptance_criteria":"q/Ctrl-C/Esc/'/' bindings wired; footer hint on each view; resize handled; poll-based event loop","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T01:14:48Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T02:10:40Z","closed_at":"2026-04-13T02:10:40Z","close_reason":"Global nav + keybindings wired via DashboardAction router in 02af8ab. q/Ctrl-C/Esc/'/' bindings live; footer on every view; poll-based loop with 100ms timeout; resize auto-re-lays out via ratatui frame draw each tick.","dependencies":[{"issue_id":"hm-c84","depends_on_id":"hm-djm","type":"blocks","created_at":"2026-04-12T18:15:19Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"hm-c84","depends_on_id":"hm-nck","type":"parent-child","created_at":"2026-04-12T18:15:13Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-xe5","title":"US-006: Secret viewer","description":"Show path, env, recipients, created_at, lastmodified. Value hidden by default; 'r' reveals via existing get code path. 'y' copies to clipboard via arboard (graceful no-op if unavailable). 'e' triggers rekey for the current secret. Esc returns to previous view.","acceptance_criteria":"metadata shown; r reveals value; y copies via arboard; e triggers rekey; esc returns","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T01:14:39Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T02:09:54Z","closed_at":"2026-04-13T02:09:54Z","close_reason":"Implemented SecretViewerView with reveal/copy/rekey/esc, wired into router via SearchAction::OpenViewer; 6 tests passing","dependencies":[{"issue_id":"hm-xe5","depends_on_id":"hm-nck","type":"parent-child","created_at":"2026-04-12T18:15:13Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"hm-xe5","depends_on_id":"hm-rd2","type":"blocks","created_at":"2026-04-12T18:15:20Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-pp9","title":"US-005: Search view","description":"/ from dashboard opens search with focused text input. Results update as query changes. Uses extracted core of crate::cli::search (refactor search::run to split IO from logic). Results show store, path, created_at. Enter on a result navigates to secret viewer. Esc returns to dashboard.","acceptance_criteria":"search view opens on /; live results; uses extracted search core; Enter opens viewer; Esc returns to dashboard","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T01:14:32Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T02:09:46Z","closed_at":"2026-04-13T02:09:46Z","close_reason":"Landed in 02af8ab","dependencies":[{"issue_id":"hm-pp9","depends_on_id":"hm-nck","type":"parent-child","created_at":"2026-04-12T18:15:12Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"hm-pp9","depends_on_id":"hm-rd2","type":"blocks","created_at":"2026-04-12T18:15:19Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-rd2","title":"US-004: Dashboard view (envs + secrets)","description":"Two-pane layout: left = environments list, right = secrets for selected env. Header shows active store slug and env count. Arrow keys / j/k navigate envs. Right pane updates live on selection change. Data via internal Rust APIs (ls equivalent), not subprocess. Empty state message when no envs. Footer lists keybindings.","acceptance_criteria":"two-pane layout; live right-pane updates; uses internal APIs not subprocess; empty state handled; footer keybindings shown","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T01:14:25Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T01:52:00Z","closed_at":"2026-04-13T01:52:00Z","close_reason":"US-003 init wizard + US-004 dashboard view landed; DashboardView ported to ratatui with envs/secrets two-pane layout, run_init_flow event loop wired.","dependencies":[{"issue_id":"hm-rd2","depends_on_id":"hm-djm","type":"blocks","created_at":"2026-04-12T18:15:19Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"hm-rd2","depends_on_id":"hm-nck","type":"parent-child","created_at":"2026-04-12T18:15:12Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":1,"dependent_count":2,"comment_count":0} +{"_type":"issue","id":"hm-b49","title":"US-003: Init wizard view (ratatui)","description":"Port the 3-step init wizard to ratatui: Step 1 data dir text input pre-filled from config::data_dir(); Step 2 remote slug validated with config::validate_remote_slug; Step 3 key provider select (hidden if macOS Keychain unavailable). Enter advances, Esc back, Ctrl-C aborts. On confirm calls init::run_init directly (not subprocess). Success screen shows pubkey and advances to dashboard. Replaces init::run_wizard cliclack path when TTY and no --json/--no-tui.","acceptance_criteria":"three steps rendered; validation wired; calls init::run_init directly; cliclack path removed for TTY case; success screen advances to dashboard","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T01:14:18Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T01:52:37Z","closed_at":"2026-04-13T01:52:37Z","close_reason":"ratatui init wizard landed: 3-step flow (data dir/remote slug/key provider), validation wired, calls init::run_init directly, cliclack TTY path removed, success advances to dashboard","dependencies":[{"issue_id":"hm-b49","depends_on_id":"hm-djm","type":"blocks","created_at":"2026-04-12T18:15:18Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"hm-b49","depends_on_id":"hm-nck","type":"parent-child","created_at":"2026-04-12T18:15:11Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-4y7","title":"US-002: Remove legacy Bun/OpenTUI TUI","description":"Delete the entire tui/ directory and remove himitsuTUI derivation + bun2nix input from flake.nix. Remove any himitsu-tui references from nativeBuildInputs and the HIMITSU_BIN env forwarding from launch_tui().","acceptance_criteria":"tui/ directory deleted; flake.nix has no himitsuTUI/bun2nix refs; HIMITSU_BIN forwarding removed; nix build still succeeds","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T01:14:08Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T01:43:39Z","closed_at":"2026-04-13T01:43:39Z","close_reason":"Deleted tui/, dropped bun2nix input, removed himitsuTUI deriv. nix flake check + cargo test/clippy/fmt all green.","dependencies":[{"issue_id":"hm-4y7","depends_on_id":"hm-djm","type":"blocks","created_at":"2026-04-12T18:15:18Z","created_by":"Cooper Maruyama","metadata":"{}"},{"issue_id":"hm-4y7","depends_on_id":"hm-nck","type":"parent-child","created_at":"2026-04-12T18:15:11Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-djm","title":"US-001: Scaffold ratatui module and wire up entry point","description":"Add ratatui/crossterm to rust/Cargo.toml and create rust/src/tui/ with mod.rs, app.rs, event.rs. Public entry himitsu::tui::run() installs/tears down raw mode + alternate screen, runs a no-op event loop that exits on q or Ctrl-C. Cli::launch_tui() in rust/src/cli/mod.rs calls tui::run() directly instead of spawning himitsu-tui. Terminal state restored on panic via drop guard or set_hook.","acceptance_criteria":"ratatui+crossterm in Cargo.toml; rust/src/tui/{mod,app,event}.rs created; himitsu::tui::run() exists; launch_tui calls it in-process; raw mode restored on panic","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-13T01:14:01Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T01:17:48Z","closed_at":"2026-04-13T01:17:48Z","close_reason":"Closed","dependencies":[{"issue_id":"hm-djm","depends_on_id":"hm-nck","type":"parent-child","created_at":"2026-04-12T18:15:11Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":0,"dependent_count":4,"comment_count":0} +{"_type":"issue","id":"hm-nck","title":"Replace OpenTUI TUI with Rust/ratatui","description":"Replace the Bun + @opentui/core TUI (in tui/, launched as a subprocess via himitsu-tui) with a pure-Rust TUI built on ratatui + crossterm, living inside the existing himitsu binary at rust/src/tui/. Running 'himitsu' with no subcommand will launch the new TUI in-process, eliminating the subprocess hop, the HIMITSU_BIN fallback, and the entire Bun toolchain from the build. Full PRD in commit history / conversation.","status":"closed","priority":2,"issue_type":"epic","owner":"demo@himitsu.dev","created_at":"2026-04-13T01:13:45Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T02:10:40Z","closed_at":"2026-04-13T02:10:40Z","close_reason":"all steps complete","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-xx0","title":"himitsu 'add' should behave like an alias for himitsu 'set'","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-12T23:51:12Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T01:17:38Z","closed_at":"2026-04-13T01:17:38Z","close_reason":"Added clap alias='add' to Set command in rust/src/cli/mod.rs","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-3w1","title":"running 'himitsu' with no args should launch the tui","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-04-12T23:50:49Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T01:17:38Z","closed_at":"2026-04-13T01:17:38Z","close_reason":"Already implemented — cdf8757 feat: launch TUI when himitsu is run with no subcommand","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-457","title":"running 'himitsu' with no args should launch the tui","status":"closed","priority":2,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-12T13:39:46Z","created_by":"Cooper Maruyama","updated_at":"2026-04-12T13:47:48Z","closed_at":"2026-04-12T13:47:48Z","close_reason":"Implemented: running himitsu with no args now launches the TUI (himitsu-tui binary). Falls back to a helpful error if TUI not installed.","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-anf","title":"himitsu 'add' should behave like an alias for himitsu 'set'","status":"closed","priority":2,"issue_type":"task","owner":"demo@himitsu.dev","created_at":"2026-04-12T13:38:51Z","created_by":"Cooper Maruyama","updated_at":"2026-04-13T01:17:38Z","closed_at":"2026-04-13T01:17:38Z","close_reason":"Added clap alias='add' to Set command in rust/src/cli/mod.rs","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-5xm","title":"visually distinguish CLI-parity entries in the command palette","description":"`tui/views/command_palette.rs:43-60,154-170` lists 16 entries (RecipientLs, RecipientAdd, RecipientRm, RecipientShow, RemoteList, RemoteRemove, RemoteSetDefault, ContextShow, ContextSet, ContextClear, Generate, Export, Check, Docs, Import, Git) that exist as discoverability bridges but Enter on any of them only toasts `run from CLI: \u003cinvocation\u003e` instead of opening a form. Documented at `command_palette.rs:23-28` and tested at line 503-531 — by design. But the palette descriptions ('Export secrets matching a glob to a SOPS file', etc.) read like the action will run. Suggest dimming these entries or appending '(CLI)' so users telegraph that Enter prints a hint rather than performs the action.","status":"closed","priority":3,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-10T09:08:09Z","created_by":"Cooper Maruyama","updated_at":"2026-05-10T10:28:31Z","started_at":"2026-05-10T09:23:32Z","closed_at":"2026-05-10T10:28:31Z","close_reason":"Implemented and verified with cargo fmt --all -- --check, cargo test --workspace, and cargo clippy --workspace --all-targets -- -D warnings","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-vho","title":"init wizard ignores user keymap (tui.keys) — wizard keys are hardcoded","description":"`tui::run_init_flow` (`tui/mod.rs:62-113`) loads `Config.tui` for theme + nerd_fonts but never reads `tui.keys`. Wizard's `on_key` (`init_wizard.rs:195-305`) hardcodes `KeyCode::Enter/Esc/Char(c)/Up/Down`. A user with a custom `tui.keys.cancel` or `tui.keys.quit` binding silently gets no effect during init. Either route through `KeyMap` or document that wizard keys are non-customizable.","status":"closed","priority":3,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-10T09:07:56Z","created_by":"Cooper Maruyama","updated_at":"2026-05-10T10:28:31Z","started_at":"2026-05-10T09:23:32Z","closed_at":"2026-05-10T10:28:31Z","close_reason":"Implemented and verified with cargo fmt --all -- --check, cargo test --workspace, and cargo clippy --workspace --all-targets -- -D warnings","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-1lm","title":"docs: cli/mod.rs:362 says 'SOPS planned' but SOPS import is fully implemented","description":"`Command::Import` doc comment in `cli/mod.rs:362` says \"Import secrets from external stores (1Password today; SOPS planned).\" Reality: `cli/import.rs:113-115` dispatches to `run_sops` when `--sops` is set; full impl + tests at `import.rs:235+,307+,850+`. Update the comment to reflect both backends.","status":"closed","priority":3,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-10T09:07:55Z","created_by":"Cooper Maruyama","updated_at":"2026-05-10T10:28:31Z","started_at":"2026-05-10T09:23:32Z","closed_at":"2026-05-10T10:28:31Z","close_reason":"Implemented and verified with cargo fmt --all -- --check, cargo test --workspace, and cargo clippy --workspace --all-targets -- -D warnings","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-vcx","title":"init wizard duplicates --home handling — collapse to one path","description":"`tui/views/init_wizard.rs:312-327` saves `cfg.data_dir = Some(home)` itself, then constructs `InitArgs { home: None, … }` (line 365). The dedicated `--home` branch in `cli/init.rs:61-76` is therefore never taken from the wizard. It works today because `tui/mod.rs:91` re-derives `data_dir` after the wizard saves config, but two parallel 'set data_dir' code paths must stay in sync. Either pass `home: Some(self.data_dir_input.trim().into())` and delete the duplicate save block, or remove the `--home` branch in init.rs and route everything through the wizard's path.","status":"closed","priority":3,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-10T09:07:55Z","created_by":"Cooper Maruyama","updated_at":"2026-05-10T10:28:32Z","started_at":"2026-05-10T09:23:38Z","closed_at":"2026-05-10T10:28:32Z","close_reason":"Implemented and verified with cargo fmt --all -- --check, cargo test --workspace, and cargo clippy --workspace --all-targets -- -D warnings","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-pdo","title":"prune dead proto types — ShareTransport, SyncDisposition, SyncState, SyncEntryState, SharedSecret, ShareEnvelope","description":"Proto types in `proto/secrets.proto:229-235,242-277` plus their re-exports in `rust/src/proto/mod.rs:39-40` are never constructed or matched in Rust. `ShareEnvelope` only appears in `cli::schema.rs` as a JSON-schema target — so `himitsu schema dump share-envelope` prints a schema for a payload nothing emits or consumes. Either implement the share/inbox/sync-diff features that motivated these types, or strip the unused variants/messages so the schema dump doesn't suggest capabilities that don't exist.","status":"closed","priority":3,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-10T09:07:36Z","created_by":"Cooper Maruyama","updated_at":"2026-05-10T10:28:32Z","started_at":"2026-05-10T09:23:38Z","closed_at":"2026-05-10T10:28:32Z","close_reason":"Implemented and verified with cargo fmt --all -- --check, cargo test --workspace, and cargo clippy --workspace --all-targets -- -D warnings","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-c0w","title":"prune dead keyring scope-mapping surface (or wire it through)","description":"`keyring::KeyProvider::store_scope/load_scope`, the `keyring::mapping` module, the `SCOPE_SERVICE` constant, `account_for`, and `crypto::age::resolve_private_key` are all only called from their own test modules. Vestigial scope-mapping layer from an earlier per-recipient-group design. Either wire it through `Context::load_identity` for some genuine use case, or excise to reduce 'looks meaningful but isn't' surface area. Lower priority since there's no user impact today.","status":"closed","priority":3,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-10T09:07:35Z","created_by":"Cooper Maruyama","updated_at":"2026-05-10T10:28:32Z","started_at":"2026-05-10T09:23:38Z","closed_at":"2026-05-10T10:28:32Z","close_reason":"Implemented and verified with cargo fmt --all -- --check, cargo test --workspace, and cargo clippy --workspace --all-targets -- -D warnings","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-3us","title":"Document external import/export requirements","description":"Add README notes for external tool requirements used by import/export/generate flows, including op for 1Password and sops for encrypted YAML/JSON output.","status":"closed","priority":3,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-01T00:51:24Z","created_by":"Cooper Maruyama","updated_at":"2026-05-03T22:01:09Z","started_at":"2026-05-03T21:59:16Z","closed_at":"2026-05-03T22:01:09Z","close_reason":"Updated README: TUI-first quick start, search no-op --refresh wording, expanded import/export tool requirements (op, sops), new Configuration section (theme/keys/HIMITSU_TUI_THEME/auto_pull), new Sync \u0026 Store Health section (auto-commit/push, --no-push, store health states), new Demo \u0026 Recordings section (vhs regen, demo-vhs.gif vs tui-us-* roles, CI scratch behavior)","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-u7g","title":"Add README demo recording instructions","description":"Add a Demo or Recording section with the canonical local command to regenerate the VHS demo: cargo build --release \u0026\u0026 vhs demo/demo.tape, plus CI scratch-output behavior.","status":"closed","priority":3,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-01T00:51:24Z","created_by":"Cooper Maruyama","updated_at":"2026-05-03T22:01:09Z","started_at":"2026-05-03T21:59:16Z","closed_at":"2026-05-03T22:01:09Z","close_reason":"Updated README: TUI-first quick start, search no-op --refresh wording, expanded import/export tool requirements (op, sops), new Configuration section (theme/keys/HIMITSU_TUI_THEME/auto_pull), new Sync \u0026 Store Health section (auto-commit/push, --no-push, store health states), new Demo \u0026 Recordings section (vhs regen, demo-vhs.gif vs tui-us-* roles, CI scratch behavior)","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-9lg","title":"Clarify README demo GIF and tape roles","description":"Update README demo references to use demo/demo-vhs.gif as the canonical polished demo and explain demo/tui-us-* tapes as per-story regression/demo artifacts.","status":"closed","priority":3,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-05-01T00:51:23Z","created_by":"Cooper Maruyama","updated_at":"2026-05-03T22:01:09Z","started_at":"2026-05-03T21:59:17Z","closed_at":"2026-05-03T22:01:09Z","close_reason":"Updated README: TUI-first quick start, search no-op --refresh wording, expanded import/export tool requirements (op, sops), new Configuration section (theme/keys/HIMITSU_TUI_THEME/auto_pull), new Sync \u0026 Store Health section (auto-commit/push, --no-push, store health states), new Demo \u0026 Recordings section (vhs regen, demo-vhs.gif vs tui-us-* roles, CI scratch behavior)","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-cfo","title":"Distribute TUI footer actions evenly","description":"Render footer action groups in evenly distributed segments instead of left/right weighted alignment.","status":"closed","priority":3,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-30T11:54:15Z","created_by":"Cooper Maruyama","updated_at":"2026-04-30T11:56:36Z","started_at":"2026-04-30T11:54:26Z","closed_at":"2026-04-30T11:56:36Z","close_reason":"Added shared distributed footer renderer and applied it to search and envs footers","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-7q6","title":"Align TUI footer right actions","description":"Use a split footer layout so the right-most footer actions render flush right instead of relying on spacer text.","status":"closed","priority":3,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-30T10:57:00Z","created_by":"Cooper Maruyama","updated_at":"2026-04-30T10:58:24Z","started_at":"2026-04-30T10:57:22Z","closed_at":"2026-04-30T10:58:24Z","close_reason":"Updated envs footer to split left actions from right-aligned help/back actions using a horizontal layout","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-cpf","title":"TUI envs editor supports multi-entry envs","description":"The envs edit flow currently supports single-entry env labels and returns an error for labels with multiple EnvEntry rows. Extend the editor state to hold multiple entry rows with add/remove controls so existing multi-entry envs can be edited in place.","status":"closed","priority":3,"issue_type":"feature","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-30T10:45:06Z","created_by":"Cooper Maruyama","updated_at":"2026-04-30T23:23:27Z","started_at":"2026-04-30T23:23:04Z","closed_at":"2026-04-30T23:23:27Z","close_reason":"Superseded by hm-6qj envs revamp, which redesigns the editor with full multi-entry/glob support from the ground up.","dependencies":[{"issue_id":"hm-cpf","depends_on_id":"hm-h9m","type":"discovered-from","created_at":"2026-04-30T03:45:06Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-h9m","title":"TUI envs tab: edit existing env with live capture preview","description":"Follow-up to hm-zzj. v1 ships read + delete; this adds `e` to edit an existing env's entries.\n\n## UX\n\n- `e` opens the same editor as the create flow (file separately as hm-zzj-followup-create) prefilled with the selected env's current entries.\n- Right pane continues to show the live `env_resolver::resolve` output as the user edits, so capture-ref expansions (`/$1/postgres-url` against actual store secrets) update in real time.\n- Save calls `envs_mut::upsert` (which is upsert semantics — replaces existing rows).\n\n## Notes\n\n- Depends on the create flow (shares the editor widget). Build the editor once, reuse it.\n- For wildcard envs, the live preview must enumerate `available_secrets` via `crate::remote::store::list_secrets` to compute capture matches.","status":"closed","priority":3,"issue_type":"feature","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-21T21:58:07Z","created_by":"Cooper Maruyama","updated_at":"2026-04-30T10:45:15Z","started_at":"2026-04-30T10:43:17Z","closed_at":"2026-04-30T10:45:15Z","close_reason":"Added env edit flow with prefilled editor, Ctrl-S upsert save, live preview while editing, and single-entry edit tests; filed hm-cpf for multi-entry editing","dependencies":[{"issue_id":"hm-h9m","depends_on_id":"hm-udz","type":"blocks","created_at":"2026-04-21T14:58:17Z","created_by":"Cooper Maruyama","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-udz","title":"TUI envs tab: create new env flow","description":"Follow-up to hm-zzj. v1 of the envs tab shipped read + delete only. This adds the `n` flow to create a new env from inside the tab.\n\n## UX\n\n- `n` opens an inline editor split with two fields:\n 1. Label input — validated live against `validate_env_label`. Show grammar hints (concrete vs `\u003cprefix\u003e/*`).\n 2. Entries list — repeatable rows, each picking a kind (Single | Glob | Alias) and the path/key fields.\n- Save (`Ctrl+S` or `Enter` on a \"Save\" button) calls `envs_mut::upsert(label, entries, scope_hint, cwd)`; on success, reload and select the new label. On error, toast the message and keep the editor open.\n- `Esc` cancels with a confirmation if the form is dirty.\n\n## Wiring\n\n- Route through the same `View::Envs` slot or add a sub-modal — your call when implementing.\n- Reuse the keymap binding `keymap.envs` already defined; add a sub-binding under it for `n`.","status":"closed","priority":3,"issue_type":"feature","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-21T21:58:07Z","created_by":"Cooper Maruyama","updated_at":"2026-04-30T10:41:14Z","started_at":"2026-04-30T10:37:58Z","closed_at":"2026-04-30T10:41:14Z","close_reason":"Added TUI envs new-env flow with inline editor, live validation, save via envs_mut::upsert, toasts, and tests","dependency_count":0,"dependent_count":1,"comment_count":0} +{"_type":"issue","id":"hm-79o","title":"Docs: update README for search-as-root TUI flow","description":"README still references the dashboard-root flow. Update screenshots/gifs to match search-as-root, refresh the key-binding table to list current shortcuts (Ctrl+N new, Ctrl+S store, Ctrl+Y copy, ? help, Esc quit, e edit, d delete with y/n confirm).","status":"closed","priority":3,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-15T06:53:34Z","created_by":"Cooper Maruyama","updated_at":"2026-04-15T07:01:04Z","closed_at":"2026-04-15T07:01:04Z","close_reason":"Merged via parallel worktree agents; cargo test --bin himitsu tui::harness:: → 3 passed","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"hm-6ia","title":"CI: run vhs against demo tapes to catch TUI flow regressions","description":"Add a GitHub Actions job that installs vhs and runs the us-008..us-013 tapes (non-interactive, no gif diffing needed — just exit code) so broken tapes fail CI before they land. Cache cargo build, reuse release binary.","status":"closed","priority":3,"issue_type":"task","assignee":"Cooper Maruyama","owner":"demo@himitsu.dev","created_at":"2026-04-15T06:53:33Z","created_by":"Cooper Maruyama","updated_at":"2026-04-15T07:01:04Z","closed_at":"2026-04-15T07:01:04Z","close_reason":"Merged via parallel worktree agents; cargo test --bin himitsu tui::harness:: → 3 passed","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/README.md b/README.md index 96e94ad..584263b 100644 --- a/README.md +++ b/README.md @@ -490,7 +490,14 @@ that takes precedence over the file. # Default store when neither -s nor -r is given. default_store: myorg/secrets # env: HIMITSU_DEFAULT_STORE -# Where age private keys live: "disk" or "macos-keychain". +# Where the age private key lives: +# "disk" — `/key` (the default). +# "macos-keychain" — macOS Keychain entry under +# `io.darkmatter.himitsu.agekey.byfp.v1`. Switching +# to keychain on an already-initialized machine +# auto-migrates the on-disk secret into the keychain +# and removes `/key`. The pubkey file +# always stays on disk for fingerprint discovery. key_provider: disk # env: HIMITSU_KEY_PROVIDER # When true, every store-touching command first runs `git fetch` and diff --git a/proto/secrets.proto b/proto/secrets.proto index d920052..642433a 100644 --- a/proto/secrets.proto +++ b/proto/secrets.proto @@ -172,106 +172,3 @@ message RecipientInfo { // When the key was added to the store. google.protobuf.Timestamp added_at = 5; } - -// --------------------------------------------------------------------------- -// Share envelope — transport-agnostic secret sharing payload -// --------------------------------------------------------------------------- - -// The payload sent when sharing one or more secrets with an external -// recipient. Transported over GitHub PR, Nostr DM, etc. -message ShareEnvelope { - // Envelope format version (currently 1). - uint32 version = 1; - - // Identifier of the sender (age public key). - string sender_public_key = 2; - - // Identifier of the intended recipient (age public key). - string recipient_public_key = 3; - - // The individual secret payloads being shared. - repeated SharedSecret secrets = 4; - - // When this share was created. - google.protobuf.Timestamp created_at = 5; - - // An optional human-readable note from the sender. - string message = 6; - - // Transport used to deliver this envelope. - ShareTransport transport = 7; - - // Unique nonce / request-id to prevent replay. - string nonce = 8; - - // TTL in seconds after which the share should be considered expired. - // 0 means no expiry. - uint32 ttl_seconds = 9; -} - -// A single secret inside a share envelope. -message SharedSecret { - // The key name being shared. - string key_name = 1; - - // Environment of the shared secret. - string environment = 2; - - // The age ciphertext, encrypted to the recipient's public key. - bytes ciphertext = 3; - - // Content hash of the plaintext (so the recipient can verify after - // decryption). - string plaintext_hash = 4; -} - -// Transport mechanism used for sharing. -enum ShareTransport { - SHARE_TRANSPORT_UNSPECIFIED = 0; - SHARE_TRANSPORT_GITHUB_PR = 1; // GitHub Pull Request inbox - SHARE_TRANSPORT_NOSTR_DM = 2; // Nostr encrypted DM (NIP-04 / NIP-44) - SHARE_TRANSPORT_FILE = 3; // Local file exchange - SHARE_TRANSPORT_QR = 4; // QR code (small secrets only) -} - -// --------------------------------------------------------------------------- -// Sync state — tracks convergence between local and remote -// --------------------------------------------------------------------------- - -// Per-remote sync state persisted locally to enable efficient diffing. -message SyncState { - // The remote this state tracks. - string remote_id = 1; - - // Last successful sync timestamp. - google.protobuf.Timestamp last_sync = 2; - - // Per-entry state at last sync (keyed by path). - map entries = 3; - - // The git commit SHA the store was at during last sync (if applicable). - string last_commit = 4; -} - -// State of a single entry at last sync. -message SyncEntryState { - // Content hash at last sync. - string content_hash = 1; - - // Timestamp of the entry at last sync. - google.protobuf.Timestamp updated_at = 2; - - // Sync disposition. - SyncDisposition disposition = 3; -} - -// Outcome of comparing local vs remote state for one entry. -enum SyncDisposition { - SYNC_DISPOSITION_UNSPECIFIED = 0; - SYNC_DISPOSITION_IN_SYNC = 1; - SYNC_DISPOSITION_LOCAL_NEWER = 2; - SYNC_DISPOSITION_REMOTE_NEWER = 3; - SYNC_DISPOSITION_CONFLICT = 4; - SYNC_DISPOSITION_LOCAL_ONLY = 5; - SYNC_DISPOSITION_REMOTE_ONLY = 6; -} diff --git a/rust/src/cli/check.rs b/rust/src/cli/check.rs index 71edd1a..89664fc 100644 --- a/rust/src/cli/check.rs +++ b/rust/src/cli/check.rs @@ -104,7 +104,7 @@ pub fn run(args: CheckArgs, ctx: &Context) -> Result<()> { /// /// Priority: /// 1. Explicit `args.store` slug. -/// 2. Slugs referenced in a project config found in the CWD ancestry. +/// 2. Slugs referenced in global or project config. /// 3. All known stores (`list_remotes()`). fn discover_stores(args: &CheckArgs, _ctx: &Context) -> Result> { // 1. Explicit store argument @@ -113,18 +113,24 @@ fn discover_stores(args: &CheckArgs, _ctx: &Context) -> Result> { return Ok(vec![slug.clone()]); } - // 2. Project config + // 2. Global + project config + let global = config::Config::load(&config::config_path()).unwrap_or_default(); + let mut slugs = collect_stores_from_global_config(&global); if let Some((cfg, _path)) = config::load_project_config() { - let slugs = collect_stores_from_project_config(&cfg); - if !slugs.is_empty() { - return Ok(slugs.into_iter().collect()); - } + slugs.extend(collect_stores_from_project_config(&cfg)); + } + if !slugs.is_empty() { + return Ok(slugs.into_iter().collect()); } // 3. All known stores crate::remote::list_remotes() } +fn collect_stores_from_global_config(cfg: &config::Config) -> BTreeSet { + collect_store_slugs(cfg.default_store.as_ref(), &cfg.envs) +} + /// Extract unique store slugs referenced in a project config. /// /// Sources: @@ -132,15 +138,22 @@ fn discover_stores(args: &CheckArgs, _ctx: &Context) -> Result> { /// - Paths inside `envs` entries that contain an `org/repo` prefix (e.g. /// `"myorg/secrets/prod/DB_PASS"` → slug `"myorg/secrets"`). fn collect_stores_from_project_config(cfg: &config::ProjectConfig) -> BTreeSet { + collect_store_slugs(cfg.default_store.as_ref(), &cfg.envs) +} + +fn collect_store_slugs( + default_store: Option<&String>, + envs: &std::collections::BTreeMap>, +) -> BTreeSet { let mut slugs = BTreeSet::new(); - if let Some(ref s) = cfg.default_store { + if let Some(s) = default_store { if config::validate_remote_slug(s).is_ok() { slugs.insert(s.clone()); } } - for entries in cfg.envs.values() { + for entries in envs.values() { for entry in entries { // Tag selectors don't carry a path — they expand at resolve time // against whatever store the caller already chose. They cannot diff --git a/rust/src/cli/codegen.rs b/rust/src/cli/codegen.rs index 1c5f0b2..96f3bff 100644 --- a/rust/src/cli/codegen.rs +++ b/rust/src/cli/codegen.rs @@ -135,22 +135,21 @@ fn run_sops(label: &str, output_override: Option<&str>, ctx: &Context) -> Result // loading any config. `resolve` will also validate — this is defensive. validate_env_label(label)?; - // Load project config — sops mode needs `cfg.envs` to resolve the label. - let (project_cfg, _cfg_path) = config::load_project_config().ok_or_else(|| { - HimitsuError::ProjectConfigRequired( - "no project config found (himitsu.yaml); codegen needs an `envs:` map".into(), - ) - })?; + let envs = config::load_effective_envs()?; - if !project_cfg.envs.contains_key(label) { + if !envs.contains_key(label) { return Err(HimitsuError::InvalidConfig(format!("unknown env: {label}"))); } // Enumerate store secrets so the resolver can expand wildcards/globs. let secrets = crate::remote::store::list_secrets(&ctx.store, None)?; + let identity = ctx.load_identity()?; + let tag_lookup = |path: &str| { + crate::cli::get::get_decoded_with_identity(ctx, path, &identity).map(|decoded| decoded.tags) + }; // Resolve into the nested EnvNode tree. - let tree = env_resolver::resolve(&project_cfg.envs, label, &secrets)?; + let tree = env_resolver::resolve_with_tags(&envs, label, &secrets, &tag_lookup)?; // Walk the tree and decrypt each Leaf into a plaintext YAML value. let yaml_tree = materialize_tree(&tree, ctx)?; @@ -199,8 +198,9 @@ fn default_sops_output_name(label: &str) -> String { fn materialize_tree(node: &env_resolver::EnvNode, ctx: &Context) -> Result { match node { env_resolver::EnvNode::Leaf { secret_path } => { - let bytes = crate::cli::get::get_plaintext(ctx, secret_path)?; - let s = String::from_utf8(bytes).map_err(|e| { + let decoded = crate::cli::get::get_decoded(ctx, secret_path)?; + crate::cli::get::warn_if_expired(secret_path, &decoded); + let s = String::from_utf8(decoded.data).map_err(|e| { HimitsuError::DecryptionFailed(format!("non-UTF-8 secret at '{secret_path}': {e}")) })?; Ok(serde_yaml::Value::String(s)) @@ -956,6 +956,7 @@ mod tests { state_dir: tmp.path().join("state"), store, recipients_path: None, + key_provider: crate::config::KeyProvider::default(), }; let args = CodegenArgs { @@ -983,6 +984,7 @@ mod tests { state_dir: tmp.path().join("state"), store, recipients_path: None, + key_provider: crate::config::KeyProvider::default(), }; let args = CodegenArgs { @@ -1011,6 +1013,7 @@ mod tests { state_dir: tmp.path().join("state"), store, recipients_path: None, + key_provider: crate::config::KeyProvider::default(), }; let args = CodegenArgs { @@ -1041,6 +1044,7 @@ mod tests { state_dir: tmp.path().join("state"), store, recipients_path: None, + key_provider: crate::config::KeyProvider::default(), }; let args = CodegenArgs { @@ -1177,6 +1181,7 @@ mod tests { state_dir: tmp.path().join("state"), store: project.clone(), recipients_path: None, + key_provider: crate::config::KeyProvider::default(), }; let result = run_sops("ghost", None, &ctx); diff --git a/rust/src/cli/exec.rs b/rust/src/cli/exec.rs index e523958..ffcd032 100644 --- a/rust/src/cli/exec.rs +++ b/rust/src/cli/exec.rs @@ -20,7 +20,7 @@ use clap::Args; use super::Context; use crate::config::{self, env_resolver, validate_env_label}; -use crate::crypto::{age, secret_value, tags as tag_grammar}; +use crate::crypto::{secret_value, tags as tag_grammar}; use crate::error::{HimitsuError, Result}; use crate::reference::SecretRef; use crate::remote::store; @@ -74,7 +74,7 @@ pub fn run(args: ExecArgs, ctx: &Context) -> Result<()> { // Load the age identity once so we don't re-parse the key file per // resolved secret. `exec` is the first hot loop of decrypts and the // win is real. - let identity = age::read_identity(&ctx.key_path())?; + let identity = ctx.load_identity()?; let decrypted = decrypt_resolved(ctx, &identity, resolved)?; let env_map = build_env_map(decrypted, &args.tags)?; @@ -93,26 +93,29 @@ struct ResolvedRef { fn resolve_ref(ref_str: &str, ctx: &Context) -> Result> { // Env labels live in their own namespace and always win when they match // exactly: project authoring intent beats path coincidence. - if let Some((cfg, _)) = config::load_project_config() { - if cfg.envs.contains_key(ref_str) { - if config::is_wildcard_label(ref_str) { - return Err(HimitsuError::NotSupported(format!( - "exec does not support wildcard env labels ({ref_str:?}); \ + let envs = config::load_effective_envs()?; + if envs.contains_key(ref_str) { + if config::is_wildcard_label(ref_str) { + return Err(HimitsuError::NotSupported(format!( + "exec does not support wildcard env labels ({ref_str:?}); \ pass a concrete env or use `himitsu codegen` for templated output" - ))); - } - validate_env_label(ref_str)?; - let available = store::list_secrets(&ctx.store, None)?; - let tree = env_resolver::resolve(&cfg.envs, ref_str, &available)?; - let leaves = collect_env_leaves(&tree); - return Ok(leaves - .into_iter() - .map(|(key, secret_path)| ResolvedRef { - secret_path, - explicit_key: Some(key), - }) - .collect()); + ))); } + validate_env_label(ref_str)?; + let available = store::list_secrets(&ctx.store, None)?; + let identity = ctx.load_identity()?; + let tag_lookup = |path: &str| { + super::get::get_decoded_with_identity(ctx, path, &identity).map(|decoded| decoded.tags) + }; + let tree = env_resolver::resolve_with_tags(&envs, ref_str, &available, &tag_lookup)?; + let leaves = collect_env_leaves(&tree); + return Ok(leaves + .into_iter() + .map(|(key, secret_path)| ResolvedRef { + secret_path, + explicit_key: Some(key), + }) + .collect()); } if let Some(prefix) = ref_str.strip_suffix("/*") { @@ -179,6 +182,7 @@ fn decrypt_resolved( refs.into_iter() .map(|r| { let decoded = super::get::get_decoded_with_identity(ctx, &r.secret_path, identity)?; + super::get::warn_if_expired(&r.secret_path, &decoded); Ok((r, decoded)) }) .collect() diff --git a/rust/src/cli/export.rs b/rust/src/cli/export.rs index ef514e9..ba278ff 100644 --- a/rust/src/cli/export.rs +++ b/rust/src/cli/export.rs @@ -7,7 +7,7 @@ use clap::Args; use crate::cli::Context; use crate::config::{load_project_config, ProjectConfig}; -use crate::crypto::age as crypto; +use crate::crypto::{age as crypto, secret_value}; use crate::error::{HimitsuError, Result}; use crate::remote::store; @@ -36,7 +36,7 @@ pub struct ExportArgs { } pub fn run(args: ExportArgs, ctx: &Context) -> Result<()> { - let identity = crypto::read_identity(&ctx.key_path())?; + let identity = ctx.load_identity()?; // List all secrets in the store. let all_paths = store::list_secrets(&ctx.store, None)?; @@ -64,8 +64,9 @@ pub fn run(args: ExportArgs, ctx: &Context) -> Result<()> { let mut secrets: BTreeMap = BTreeMap::new(); for path in &matched { let ciphertext = store::read_secret(&ctx.store, path)?; - let plaintext_bytes = crypto::decrypt(&ciphertext, &identity)?; - let plaintext = String::from_utf8(plaintext_bytes).map_err(|e| { + let decoded = secret_value::decode(&crypto::decrypt(&ciphertext, &identity)?); + super::get::warn_if_expired(path, &decoded); + let plaintext = String::from_utf8(decoded.data).map_err(|e| { HimitsuError::DecryptionFailed(format!("non-UTF-8 secret at '{path}': {e}")) })?; secrets.insert((*path).clone(), plaintext); diff --git a/rust/src/cli/generate.rs b/rust/src/cli/generate.rs index 1d1ac9b..d1c9e31 100644 --- a/rust/src/cli/generate.rs +++ b/rust/src/cli/generate.rs @@ -6,8 +6,8 @@ use std::process::{Command as StdCommand, Stdio}; use clap::Args; use crate::cli::Context; -use crate::config::{load_project_config, EnvEntry, ProjectConfig}; -use crate::crypto::age as crypto; +use crate::config::{self, load_project_config, EnvEntry, ProjectConfig}; +use crate::crypto::{age as crypto, secret_value}; use crate::error::{HimitsuError, Result}; use crate::reference::SecretRef; use crate::remote::store; @@ -29,36 +29,32 @@ pub struct GenerateArgs { } pub fn run(args: GenerateArgs, ctx: &Context) -> Result<()> { - // Load project config — required for generate. - let (project_cfg, _cfg_path) = load_project_config().ok_or_else(|| { - HimitsuError::ProjectConfigRequired( - "no project config found (himitsu.yaml); run generate from a project root".into(), - ) - })?; - - if project_cfg.envs.is_empty() { + let project_cfg = load_project_config().map(|(cfg, _)| cfg); + let envs = config::load_effective_envs()?; + + if envs.is_empty() { return Err(HimitsuError::GenerateError( - "no `envs` defined in project config".into(), + "no `envs` defined in global or project config".into(), )); } // Load age identity for decryption. - let identity = crypto::read_identity(&ctx.key_path())?; + let identity = ctx.load_identity()?; // Determine which envs to generate. let env_names: Vec = if let Some(ref env_name) = args.env { - if !project_cfg.envs.contains_key(env_name.as_str()) { + if !envs.contains_key(env_name.as_str()) { return Err(HimitsuError::GenerateError(format!( - "env '{env_name}' not found in project config" + "env '{env_name}' not found in global or project config" ))); } vec![env_name.clone()] } else { - project_cfg.envs.keys().cloned().collect() + envs.keys().cloned().collect() }; for env_name in &env_names { - let entries = project_cfg.envs.get(env_name.as_str()).unwrap(); + let entries = envs.get(env_name.as_str()).unwrap(); // Resolve entries to (output_key, store_path, optional_store_override) tuples. let mappings = resolve_entries(entries, env_name, &ctx.store)?; @@ -73,8 +69,9 @@ pub fn run(args: GenerateArgs, ctx: &Context) -> Result<()> { for (key, path, store_override) in &mappings { let effective_store = store_override.as_deref().unwrap_or(&ctx.store); let ciphertext = store::read_secret(effective_store, path)?; - let plaintext_bytes = crypto::decrypt(&ciphertext, &identity)?; - let plaintext = String::from_utf8(plaintext_bytes).map_err(|e| { + let decoded = secret_value::decode(&crypto::decrypt(&ciphertext, &identity)?); + super::get::warn_if_expired(path, &decoded); + let plaintext = String::from_utf8(decoded.data).map_err(|e| { HimitsuError::DecryptionFailed(format!("non-UTF-8 secret at '{path}': {e}")) })?; if output.contains_key(key) { @@ -88,8 +85,13 @@ pub fn run(args: GenerateArgs, ctx: &Context) -> Result<()> { if args.stdout { print!("{yaml}"); } else { - let target_dir = resolve_target(&args, &project_cfg)?; - write_env_file(&target_dir, env_name, &yaml, &project_cfg)?; + let project_cfg = project_cfg.as_ref().ok_or_else(|| { + HimitsuError::ProjectConfigRequired( + "no project config found (himitsu.yaml); use --stdout or --target".into(), + ) + })?; + let target_dir = resolve_target(&args, project_cfg)?; + write_env_file(&target_dir, env_name, &yaml, project_cfg)?; } } diff --git a/rust/src/cli/get.rs b/rust/src/cli/get.rs index dd66f63..32bb66a 100644 --- a/rust/src/cli/get.rs +++ b/rust/src/cli/get.rs @@ -38,8 +38,8 @@ pub fn get_plaintext(ctx: &Context, path: &str) -> Result> { } /// Decrypt and return the full decoded SecretValue for a secret reference. -fn get_decoded(ctx: &Context, path: &str) -> Result { - let identity = age::read_identity(&ctx.key_path())?; +pub(crate) fn get_decoded(ctx: &Context, path: &str) -> Result { + let identity = ctx.load_identity()?; get_decoded_with_identity(ctx, path, &identity) } @@ -88,6 +88,9 @@ fn emit_metadata_block(decoded: &secret_value::Decoded) { if !decoded.env_key.is_empty() { let _ = writeln!(out, "env_key: {}", decoded.env_key); } + if !decoded.tags.is_empty() { + let _ = writeln!(out, "tags: {}", decoded.tags.join(", ")); + } if let Some(ref ts) = decoded.expires_at { if !duration::is_unset(ts) { @@ -102,6 +105,21 @@ fn emit_metadata_block(decoded: &secret_value::Decoded) { } } +pub(crate) fn warn_if_expired(path: &str, decoded: &secret_value::Decoded) { + let Some(ts) = decoded.expires_at.as_ref() else { + return; + }; + if duration::is_unset(ts) { + return; + } + let Some(dt) = duration::from_proto_timestamp(ts) else { + return; + }; + if dt <= chrono::Utc::now() { + eprintln!("warning: secret '{path}' expired at {}", dt.to_rfc3339()); + } +} + fn colorize(s: &str, sev: ExpirySeverity, is_tty: bool) -> String { if !is_tty { return s.to_string(); diff --git a/rust/src/cli/import.rs b/rust/src/cli/import.rs index 8fcf55d..2616acc 100644 --- a/rust/src/cli/import.rs +++ b/rust/src/cli/import.rs @@ -739,6 +739,7 @@ mod tests { state_dir: std::path::PathBuf::from("/tmp"), store: std::path::PathBuf::new(), recipients_path: None, + key_provider: crate::config::KeyProvider::default(), }; let err = run(args, &ctx).unwrap_err(); assert!( @@ -765,6 +766,7 @@ mod tests { state_dir: std::path::PathBuf::from("/tmp"), store: std::path::PathBuf::new(), recipients_path: None, + key_provider: crate::config::KeyProvider::default(), }; let err = run(args, &ctx).unwrap_err(); assert!( @@ -792,6 +794,7 @@ mod tests { state_dir: std::path::PathBuf::from("/tmp"), store: std::path::PathBuf::new(), recipients_path: None, + key_provider: crate::config::KeyProvider::default(), }; let err = run(args, &ctx).unwrap_err(); assert!( diff --git a/rust/src/cli/init.rs b/rust/src/cli/init.rs index 6a0d173..bab3d5a 100644 --- a/rust/src/cli/init.rs +++ b/rust/src/cli/init.rs @@ -70,6 +70,7 @@ pub fn run(args: InitArgs, ctx: &Context) -> Result<()> { state_dir: config::state_dir(), store: ctx.store.clone(), recipients_path: ctx.recipients_path.clone(), + key_provider: ctx.key_provider.clone(), }; return run_init(args, &patched_ctx); } @@ -83,41 +84,51 @@ pub(crate) fn run_init(args: InitArgs, ctx: &Context) -> Result<()> { let data_dir = &ctx.data_dir; let state_dir = &ctx.state_dir; - // ── 1. Ensure data_dir exists (keys, config) ────────────────────────── - let key_existed = data_dir.join("key").exists(); - + // ── 1. Ensure config exists, then resolve the active provider ──────── + // The provider must be settled BEFORE we write any key material, since + // it decides whether the secret lands on disk or in the keychain. With + // the old order (write key → set provider), `--key-provider macos-keychain` + // produced a config that pointed at the keychain while the secret sat + // in `data_dir/key` — fingers-crossed that no one read it. (Bug fix.) std::fs::create_dir_all(data_dir)?; - let key_path = data_dir.join("key"); - let pubkey_path = data_dir.join("key.pub"); - - let pubkey = if !key_path.exists() { - let (secret, public) = age::keygen(); - std::fs::write( - &key_path, - format!( - "# created: {}\n# public key: {public}\n{secret}\n", - timestamp() - ), - )?; - std::fs::write(&pubkey_path, format!("{public}\n"))?; - public - } else { - read_public_key(data_dir)? - }; - let config_path = config::config_path(); if !config_path.exists() { config::Config::write_default(&config_path)?; } - // ── 2. Handle --key-provider ────────────────────────────────────────── if let Some(ref provider_str) = args.key_provider { let provider: KeyProvider = provider_str.parse()?; let mut cfg = config::Config::load(&config_path)?; cfg.key_provider = provider; cfg.save(&config_path)?; } + let active_provider = config::Config::load(&config_path)?.key_provider; + + // If the user just switched to keychain on an already-initialized + // machine, move the existing on-disk secret into the keychain. The + // pubkey file stays in place — it's the provider-agnostic "is + // initialized" probe. + if crate::crypto::keystore::needs_disk_to_keychain_migration(&active_provider, data_dir)? { + crate::crypto::keystore::migrate_disk_to_keychain(data_dir)?; + eprintln!("✓ Migrated age key from disk to macOS Keychain"); + } + + // ── 2. Generate a fresh keypair if none exists yet ─────────────────── + let key_existed = crate::crypto::keystore::is_initialized(data_dir); + let pubkey = if !key_existed { + let (secret, public) = age::keygen(); + crate::crypto::keystore::store_new_key( + &active_provider, + data_dir, + &secret, + &public, + ×tamp(), + )?; + public + } else { + read_public_key(data_dir)? + }; // ── 3. Ensure state_dir exists (stores subdir) ──────────────────────── std::fs::create_dir_all(state_dir.join("stores"))?; @@ -317,14 +328,20 @@ fn detect_personal_github_username() -> Option { None } -/// Build a default project-store slug suggestion: `/secrets`, -/// where `` is the GitHub org of the current repo's `origin` -/// remote. Returns an empty string when not in a git repo or when the origin -/// can't be parsed as a GitHub slug. -pub(crate) fn suggested_project_slug() -> String { - detect_origin_github_org() - .map(|org| format!("{org}/secrets")) - .unwrap_or_default() +/// Build a default project-store slug suggestion (`/secrets`) +/// from a known git root. The TUI wizard captures the repo at +/// construction time and passes it here so the suggestion is locked to +/// that repo — important because `current_dir()` can drift during the +/// auto-init bootstrap or be different from where the user expected +/// the wizard to derive the slug. +pub(crate) fn suggested_project_slug_for(git_root: &Path) -> String { + let Some(slug) = detect_origin_remote(&git_root.to_path_buf()) else { + return String::new(); + }; + let Some((org, _)) = slug.split_once('/') else { + return String::new(); + }; + format!("{org}/secrets") } fn detect_origin_github_org() -> Option { diff --git a/rust/src/cli/join.rs b/rust/src/cli/join.rs index 50c4d7a..27c1f79 100644 --- a/rust/src/cli/join.rs +++ b/rust/src/cli/join.rs @@ -119,6 +119,7 @@ mod tests { state_dir: tmp.path().join("state"), store, recipients_path: None, + key_provider: crate::config::KeyProvider::default(), } } diff --git a/rust/src/cli/ls.rs b/rust/src/cli/ls.rs index 1fbf9e2..6b2a6c7 100644 --- a/rust/src/cli/ls.rs +++ b/rust/src/cli/ls.rs @@ -76,7 +76,7 @@ pub fn run(args: LsArgs, ctx: &Context) -> Result<()> { let identity = if args.tag.is_empty() { None } else { - age::read_identity(&ctx.key_path()).ok() + ctx.load_identity().ok() }; // ── Resolve qualified references ────────────────────────────────────── diff --git a/rust/src/cli/mod.rs b/rust/src/cli/mod.rs index 847da27..f62edbf 100644 --- a/rust/src/cli/mod.rs +++ b/rust/src/cli/mod.rs @@ -51,18 +51,31 @@ pub struct Context { /// from the project-level `himitsu.yaml` `store.recipients_path` field. /// When `None`, the default `.himitsu/recipients/` layout is used. pub recipients_path: Option, + /// Where the age private key lives. Resolved from `Config.key_provider` + /// at dispatcher boot so callers don't each re-read the config. + pub key_provider: crate::config::KeyProvider, } impl Context { - /// Path to the age private key file. + /// Path to the age private key file. Only valid for the + /// [`Disk`](crate::config::KeyProvider::Disk) provider — with the + /// keychain provider this path doesn't exist, so callers should + /// reach the secret through [`Self::load_identity`] instead of + /// reading the path directly. pub fn key_path(&self) -> PathBuf { - self.data_dir.join("key") + crate::crypto::keystore::disk_secret_path(&self.data_dir) } - /// Path to the age public key file. + /// Path to the age public key file. Always written (provider-agnostic). #[allow(dead_code)] pub fn pubkey_path(&self) -> PathBuf { - self.data_dir.join("key.pub") + crate::crypto::keystore::pubkey_path(&self.data_dir) + } + + /// Load the user's age identity through the active provider. This is + /// the chokepoint: every command that decrypts goes through it. + pub fn load_identity(&self) -> Result<::age::x25519::Identity> { + crate::crypto::keystore::load_identity(&self.key_provider, &self.data_dir) } /// Directory containing managed store checkouts. @@ -346,7 +359,7 @@ pub enum Command { #[command(hide = true)] Inbox(inbox::InboxArgs), - /// Import secrets from external stores (1Password today; SOPS planned). + /// Import secrets from external stores (1Password or SOPS). Import(import::ImportArgs), } @@ -377,7 +390,7 @@ impl Cli { && !is_docs && !is_completions && !is_complete_paths - && !data_dir.join("key").exists() + && !crate::crypto::keystore::is_initialized(&data_dir) { eprintln!("First run — initializing himitsu..."); let ctx = Context { @@ -385,6 +398,7 @@ impl Cli { state_dir: state_dir.clone(), store: PathBuf::new(), recipients_path: None, + key_provider: crate::config::KeyProvider::default(), }; init::run( init::InitArgs { @@ -425,7 +439,6 @@ impl Cli { | Command::Export(_) | Command::Codegen(_) | Command::Exec(_) - | Command::Share(_) | Command::Import(_) | Command::Tag(_) ); @@ -463,11 +476,15 @@ impl Cli { } let recipients_path = load_recipients_path_override(&store); + let key_provider = crate::config::Config::load(&crate::config::config_path()) + .map(|c| c.key_provider) + .unwrap_or_default(); let ctx = Context { data_dir, state_dir, store, recipients_path, + key_provider, }; // Pre-dispatch: when `auto_pull` is on, fetch + fast-forward the @@ -568,12 +585,16 @@ impl Cli { // than erroring out. let store = crate::config::resolve_store(None).unwrap_or_default(); let recipients_path = load_recipients_path_override(&store); + let key_provider = crate::config::Config::load(&crate::config::config_path()) + .map(|c| c.key_provider) + .unwrap_or_default(); let ctx = Context { data_dir, state_dir, store, recipients_path, + key_provider, }; crate::tui::run(&ctx) } @@ -611,8 +632,6 @@ fn mutation_message(cmd: &Command) -> Option { schema::SchemaCommand::Refresh => Some("schema refresh".to_string()), _ => None, }, - Command::Share(_) => Some("share".to_string()), - Command::Inbox(_) => Some("inbox".to_string()), Command::Tag(a) => match &a.action { tag::TagAction::Add { .. } => Some(format!("tag add {}", a.path)), tag::TagAction::Rm { .. } => Some(format!("tag rm {}", a.path)), @@ -650,7 +669,6 @@ fn command_uses_explicit_path_store(command: &Command) -> bool { | Command::Export(_) | Command::Codegen(_) | Command::Exec(_) - | Command::Share(_) | Command::Import(_) | Command::Tag(_) ) @@ -857,6 +875,7 @@ mod tests { state_dir: tmp, store: store.to_path_buf(), recipients_path: None, + key_provider: crate::config::KeyProvider::default(), } } diff --git a/rust/src/cli/recipient.rs b/rust/src/cli/recipient.rs index a8db309..62d1ca7 100644 --- a/rust/src/cli/recipient.rs +++ b/rust/src/cli/recipient.rs @@ -435,6 +435,7 @@ mod tests { state_dir: tmp.path().join("state"), store, recipients_path: None, + key_provider: crate::config::KeyProvider::default(), }; (tmp, ctx) } diff --git a/rust/src/cli/rekey.rs b/rust/src/cli/rekey.rs index 9036721..2ca956c 100644 --- a/rust/src/cli/rekey.rs +++ b/rust/src/cli/rekey.rs @@ -10,7 +10,7 @@ use crate::remote::store; pub struct RekeyArgs { /// Path prefix to filter which secrets to re-encrypt. If omitted, re-encrypts all. pub path: Option, - /// Always re-encrypt, even if nothing appears to have changed. + /// Reserved; rekey currently always re-encrypts matched secrets. #[arg(long)] pub force: bool, } @@ -23,7 +23,7 @@ pub struct RekeyArgs { /// Note: `args.force` is accepted for forward-compat (future no-op detection) /// but currently all matched secrets are always re-encrypted. pub fn rekey_store(ctx: &Context, path_prefix: Option<&str>) -> Result { - let identity = age::read_identity(&ctx.key_path())?; + let identity = ctx.load_identity()?; let recipients = age::collect_recipients(&ctx.store, ctx.recipients_path.as_deref())?; if recipients.is_empty() { return Err(HimitsuError::Recipient("no recipients found".into())); diff --git a/rust/src/cli/schema.rs b/rust/src/cli/schema.rs index 329cd4d..cb2a361 100644 --- a/rust/src/cli/schema.rs +++ b/rust/src/cli/schema.rs @@ -48,18 +48,12 @@ pub enum SchemaName { Config, /// Encrypted secret envelope (on-disk wrapper). SecretEnvelope, - /// Share envelope (transport payload). - ShareEnvelope, } impl SchemaName { /// All available schema names, in definition order. fn all() -> &'static [SchemaName] { - &[ - SchemaName::Config, - SchemaName::SecretEnvelope, - SchemaName::ShareEnvelope, - ] + &[SchemaName::Config, SchemaName::SecretEnvelope] } /// The canonical file name for this schema (without directory). @@ -67,7 +61,6 @@ impl SchemaName { match self { SchemaName::Config => "config.schema.json", SchemaName::SecretEnvelope => "secret-envelope.schema.json", - SchemaName::ShareEnvelope => "share-envelope.schema.json", } } @@ -76,7 +69,6 @@ impl SchemaName { match self { SchemaName::Config => "Unified config (.himitsu.yaml)", SchemaName::SecretEnvelope => "Encrypted secret envelope (on-disk wrapper)", - SchemaName::ShareEnvelope => "Share envelope (transport-agnostic sharing payload)", } } @@ -85,7 +77,6 @@ impl SchemaName { match self { SchemaName::Config => proto::config_json_schema(), SchemaName::SecretEnvelope => proto::secret_envelope_json_schema(), - SchemaName::ShareEnvelope => proto::share_envelope_json_schema(), } } } @@ -95,7 +86,6 @@ impl std::fmt::Display for SchemaName { match self { SchemaName::Config => write!(f, "config"), SchemaName::SecretEnvelope => write!(f, "secret-envelope"), - SchemaName::ShareEnvelope => write!(f, "share-envelope"), } } } @@ -257,6 +247,7 @@ mod tests { state_dir: tmp.path().join("state"), store: store.clone(), recipients_path: None, + key_provider: crate::config::KeyProvider::default(), }; cmd_refresh(&ctx).unwrap(); diff --git a/rust/src/cli/search.rs b/rust/src/cli/search.rs index 051b128..1fcc0ff 100644 --- a/rust/src/cli/search.rs +++ b/rust/src/cli/search.rs @@ -79,7 +79,7 @@ pub fn search_core(ctx: &Context, query: &str, tag_filter: &[String]) -> Result< // description from each secret's encrypted payload. If the identity // isn't available (fresh install, CI test fixture, missing key file) // we still return search results — just without descriptions. - let identity = age::read_identity(&ctx.key_path()).ok(); + let identity = ctx.load_identity().ok(); for (slug, store_path) in collect_stores(ctx)? { let paths = store::list_secrets(&store_path, None).unwrap_or_default(); diff --git a/rust/src/cli/sync.rs b/rust/src/cli/sync.rs index ef5be16..4e76b09 100644 --- a/rust/src/cli/sync.rs +++ b/rust/src/cli/sync.rs @@ -44,6 +44,7 @@ pub fn run(args: SyncArgs, ctx: &Context) -> Result<()> { state_dir: ctx.state_dir.clone(), store: store_path.clone(), recipients_path: None, + key_provider: ctx.key_provider.clone(), }; // Commit any pre-existing pending changes (e.g. from a prior sync that diff --git a/rust/src/cli/tag.rs b/rust/src/cli/tag.rs index e5c42d0..3285e4e 100644 --- a/rust/src/cli/tag.rs +++ b/rust/src/cli/tag.rs @@ -60,7 +60,7 @@ pub fn run(args: TagArgs, ctx: &Context) -> Result<()> { }; let ciphertext = store::read_secret(&effective_store, &secret_path)?; - let identity = age::read_identity(&ctx.key_path())?; + let identity = ctx.load_identity()?; let plaintext = age::decrypt(&ciphertext, &identity)?; let mut decoded = secret_value::decode(&plaintext); diff --git a/rust/src/config/mod.rs b/rust/src/config/mod.rs index b28d35f..1ce4603 100644 --- a/rust/src/config/mod.rs +++ b/rust/src/config/mod.rs @@ -719,6 +719,30 @@ pub fn load_project_config() -> Option<(ProjectConfig, PathBuf)> { Some((cfg, path)) } +/// Merge global and project env definitions with project labels taking +/// precedence. This is the env lookup surface used by CLI consumers; the TUI +/// can still show scopes separately when scope matters for editing. +pub fn merge_envs( + global: &BTreeMap>, + project: Option<&BTreeMap>>, +) -> BTreeMap> { + let mut merged = global.clone(); + if let Some(project) = project { + merged.extend(project.clone()); + } + merged +} + +/// Load global + project env definitions for command resolution. +pub fn load_effective_envs() -> Result>> { + let global = Config::load(&config_path())?; + let project = load_project_config().map(|(cfg, _)| cfg); + Ok(merge_envs( + &global.envs, + project.as_ref().map(|cfg| &cfg.envs), + )) +} + // ── Store resolution ──────────────────────────────────────────────────────── /// Validate a remote slug (e.g., `"org/repo"`). @@ -1046,6 +1070,32 @@ envs: assert!(cfg2.envs.contains_key("prod/*")); } + #[test] + fn merge_envs_keeps_global_and_project_overrides_conflicts() { + let mut global = BTreeMap::new(); + global.insert( + "shared".to_string(), + vec![EnvEntry::Single("global/SHARED".into())], + ); + global.insert( + "global-only".to_string(), + vec![EnvEntry::Single("global/ONLY".into())], + ); + + let mut project = BTreeMap::new(); + project.insert( + "shared".to_string(), + vec![EnvEntry::Single("project/SHARED".into())], + ); + + let merged = merge_envs(&global, Some(&project)); + assert!(matches!( + &merged["shared"][0], + EnvEntry::Single(path) if path == "project/SHARED" + )); + assert!(merged.contains_key("global-only")); + } + #[test] fn config_validate_rejects_bad_env_label() { let mut cfg = Config::default(); diff --git a/rust/src/crypto/age.rs b/rust/src/crypto/age.rs index 4480748..d587bb9 100644 --- a/rust/src/crypto/age.rs +++ b/rust/src/crypto/age.rs @@ -5,7 +5,6 @@ use ::age::x25519::{Identity, Recipient}; use secrecy::ExposeSecret; use crate::error::{HimitsuError, Result}; -use crate::keyring::KeyProvider; /// Generate a new age x25519 keypair. /// Returns (secret_key_string, public_key_string). @@ -244,32 +243,9 @@ fn collect_recipients_recursive(dir: &Path, out: &mut Vec) -> Result< Ok(()) } -/// Resolve the private key for a given scope. -/// Checks the keychain provider first if supplied. Falls back to reading from the given `fallback_path`. -pub fn resolve_private_key( - scope: &str, - fallback_path: &Path, - provider: Option<&dyn KeyProvider>, -) -> Result { - if let Some(p) = provider { - if let Ok(Some(fingerprint)) = p.load_scope(scope) { - if let Ok(Some(secret)) = p.load_key(&fingerprint) { - if let Ok(identity) = parse_identity(&secret) { - return Ok(identity); - } - } - } - } - - // Fallback to file - read_identity(fallback_path) -} - #[cfg(test)] mod tests { use super::*; - use crate::keyring::MockKeyProvider; - use std::io::Write; #[test] fn keygen_produces_valid_keypair() { @@ -326,36 +302,6 @@ mod tests { assert!(result.is_err()); } - #[test] - fn resolve_private_key_prefers_keychain_when_enabled_and_falls_back() { - let (secret, public) = keygen(); - let scope = "gh:org:repo:team"; - let fp = crate::keyring::fingerprint(&public); - - let provider = MockKeyProvider::new(); - provider.store_scope(scope, &fp).unwrap(); - provider.store_key(&fp, &secret).unwrap(); - - // 1. Should load from keychain successfully (even without fallback file) - let empty_path = Path::new("/nonexistent"); - let identity = resolve_private_key(scope, empty_path, Some(&provider)).unwrap(); - // Since we got here without error and file doesn't exist, it used the keychain - assert_eq!(identity.to_string().expose_secret(), &secret); - - // 2. Should fall back to file if keychain fails or isn't provided - let mut temp_file = tempfile::NamedTempFile::new().unwrap(); - temp_file.write_all(secret.as_bytes()).unwrap(); - - let identity_fallback = resolve_private_key(scope, temp_file.path(), None).unwrap(); - assert_eq!(identity_fallback.to_string().expose_secret(), &secret); - - // 3. Should fall back to file if keychain scope is missing - let empty_provider = MockKeyProvider::new(); - let identity_fallback_2 = - resolve_private_key(scope, temp_file.path(), Some(&empty_provider)).unwrap(); - assert_eq!(identity_fallback_2.to_string().expose_secret(), &secret); - } - #[test] fn recipient_matches_exact() { assert!(super::recipient_matches("alice", "alice")); diff --git a/rust/src/crypto/keystore.rs b/rust/src/crypto/keystore.rs new file mode 100644 index 0000000..bdf4072 --- /dev/null +++ b/rust/src/crypto/keystore.rs @@ -0,0 +1,230 @@ +//! Provider-aware persistence for the user's age private key. +//! +//! The private key can live in two places, picked by `Config.key_provider`: +//! +//! - [`Disk`](config::KeyProvider::Disk) — a file at `data_dir/key`. +//! - [`MacosKeychain`](config::KeyProvider::MacosKeychain) — the macOS +//! Keychain (a `generic-password` entry under +//! `io.darkmatter.himitsu.agekey.byfp.v1`). +//! +//! Either way the **public key** stays at `data_dir/key.pub` so other +//! commands can compute the fingerprint without unlocking the keychain. +//! The pubkey file is also the canonical "is himitsu initialized?" probe. +//! +//! Most callers don't reach in here directly — they go through +//! [`Context::load_identity`](crate::cli::Context::load_identity), which +//! is the chokepoint that resolves the active provider once. + +use std::path::Path; + +use ::age::x25519::Identity; + +use crate::config::KeyProvider as ProviderChoice; +use crate::crypto::age; +use crate::error::{HimitsuError, Result}; +use crate::keyring::macos::MacOSKeychain; +use crate::keyring::{fingerprint, KeyProvider}; + +/// Path to the on-disk public-key file. Always written, regardless of +/// which provider holds the secret. +pub fn pubkey_path(data_dir: &Path) -> std::path::PathBuf { + data_dir.join("key.pub") +} + +/// Path to the on-disk secret-key file. Only populated when the active +/// provider is [`ProviderChoice::Disk`]; with the keychain provider this +/// path doesn't exist. +pub fn disk_secret_path(data_dir: &Path) -> std::path::PathBuf { + data_dir.join("key") +} + +/// Has the user run `himitsu init`? True when a public-key file is +/// present — the secret can live on disk or in the keychain, but the +/// pubkey is always materialised so this is a provider-agnostic probe. +pub fn is_initialized(data_dir: &Path) -> bool { + pubkey_path(data_dir).exists() +} + +/// Persist a freshly-generated keypair under the given provider. +/// +/// `pubkey_path` always gets written. `secret`'s landing site depends on +/// `provider`: disk drops it next to the pubkey with the legacy +/// `# comment` header; keychain stores it under the public key's +/// fingerprint and never touches the disk secret file. +pub fn store_new_key( + provider: &ProviderChoice, + data_dir: &Path, + secret: &str, + pubkey: &str, + timestamp: &str, +) -> Result<()> { + std::fs::create_dir_all(data_dir)?; + std::fs::write(pubkey_path(data_dir), format!("{pubkey}\n"))?; + + match provider { + ProviderChoice::Disk => { + std::fs::write( + disk_secret_path(data_dir), + format!("# created: {timestamp}\n# public key: {pubkey}\n{secret}\n"), + )?; + } + ProviderChoice::MacosKeychain => { + ensure_keychain_available()?; + let fp = fingerprint(pubkey); + MacOSKeychain.store_key(&fp, secret)?; + } + } + Ok(()) +} + +/// Load the active identity for the configured provider. +/// +/// Disk: parses `data_dir/key`. Keychain: looks up the secret indexed +/// by the disk pubkey's fingerprint, then parses it. +pub fn load_identity(provider: &ProviderChoice, data_dir: &Path) -> Result { + match provider { + ProviderChoice::Disk => age::read_identity(&disk_secret_path(data_dir)), + ProviderChoice::MacosKeychain => { + ensure_keychain_available()?; + let pubkey = std::fs::read_to_string(pubkey_path(data_dir)) + .map_err(|e| { + HimitsuError::Keychain(format!( + "no public key file at {} (run `himitsu init`): {e}", + pubkey_path(data_dir).display() + )) + })? + .trim() + .to_string(); + let fp = fingerprint(&pubkey); + let secret = MacOSKeychain.load_key(&fp)?.ok_or_else(|| { + HimitsuError::Keychain(format!( + "no key for fingerprint {fp} in macOS Keychain — \ + run `himitsu init --key-provider macos-keychain` to migrate \ + from disk, or check that the entry under \ + io.darkmatter.himitsu.agekey.byfp.v1 / {fp} hasn't been deleted" + )) + })?; + age::parse_identity(&secret) + } + } +} + +/// Detect a disk-based key that should be migrated to the keychain. +/// +/// Returns `true` only when the active provider is keychain, the disk +/// secret file exists, and the keychain has no entry for the same +/// fingerprint yet — i.e. a one-shot migration is wanted, not idempotent +/// rewrites of an already-migrated key. +pub fn needs_disk_to_keychain_migration( + provider: &ProviderChoice, + data_dir: &Path, +) -> Result { + if !matches!(provider, ProviderChoice::MacosKeychain) { + return Ok(false); + } + if !disk_secret_path(data_dir).exists() { + return Ok(false); + } + if !MacOSKeychain::is_available() { + return Ok(false); + } + let pubkey = std::fs::read_to_string(pubkey_path(data_dir))? + .trim() + .to_string(); + let fp = fingerprint(&pubkey); + Ok(MacOSKeychain.load_key(&fp)?.is_none()) +} + +/// Move an existing on-disk secret into the keychain, then delete the +/// disk file. The pubkey file is left in place (still needed for +/// fingerprint discovery). +/// +/// No-op if the disk secret has already been migrated. Errors out +/// without touching state if the keychain write fails — callers can +/// safely retry. +pub fn migrate_disk_to_keychain(data_dir: &Path) -> Result<()> { + ensure_keychain_available()?; + let secret_path = disk_secret_path(data_dir); + if !secret_path.exists() { + return Ok(()); + } + let pubkey = std::fs::read_to_string(pubkey_path(data_dir))? + .trim() + .to_string(); + let fp = fingerprint(&pubkey); + let identity = age::read_identity(&secret_path)?; + let secret_str = secrecy::ExposeSecret::expose_secret(&identity.to_string()).to_string(); + MacOSKeychain.store_key(&fp, &secret_str)?; + // Only remove the disk file once the keychain write succeeded. + std::fs::remove_file(&secret_path)?; + Ok(()) +} + +fn ensure_keychain_available() -> Result<()> { + if !MacOSKeychain::is_available() { + return Err(HimitsuError::Keychain( + "macOS Keychain provider selected but this isn't macOS — \ + switch `key_provider` to `disk` in ~/.config/himitsu/config.yaml" + .into(), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_initialized_tracks_pubkey_file_only() { + let dir = tempfile::tempdir().unwrap(); + assert!(!is_initialized(dir.path())); + + // Secret-only is NOT initialized — pubkey is the canonical probe. + std::fs::write(disk_secret_path(dir.path()), "AGE-SECRET-KEY-...").unwrap(); + assert!(!is_initialized(dir.path())); + + std::fs::write(pubkey_path(dir.path()), "age1pub").unwrap(); + assert!(is_initialized(dir.path())); + } + + #[test] + fn store_new_key_disk_writes_both_files() { + let dir = tempfile::tempdir().unwrap(); + store_new_key( + &ProviderChoice::Disk, + dir.path(), + "AGE-SECRET-KEY-1ABCDEF", + "age1publicfake", + "2026-05-09T12:00:00Z", + ) + .unwrap(); + + let pub_contents = std::fs::read_to_string(pubkey_path(dir.path())).unwrap(); + assert!(pub_contents.contains("age1publicfake")); + + let secret_contents = std::fs::read_to_string(disk_secret_path(dir.path())).unwrap(); + assert!(secret_contents.contains("AGE-SECRET-KEY-1ABCDEF")); + assert!(secret_contents.contains("# created: 2026-05-09T12:00:00Z")); + assert!(secret_contents.contains("# public key: age1publicfake")); + } + + #[test] + fn migration_predicate_false_for_disk_provider() { + let dir = tempfile::tempdir().unwrap(); + // Disk provider never migrates regardless of disk state. + std::fs::write(pubkey_path(dir.path()), "age1pub").unwrap(); + std::fs::write(disk_secret_path(dir.path()), "secret").unwrap(); + assert!(!needs_disk_to_keychain_migration(&ProviderChoice::Disk, dir.path()).unwrap()); + } + + #[test] + fn migration_predicate_false_when_disk_secret_missing() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(pubkey_path(dir.path()), "age1pub").unwrap(); + // Keychain provider + no on-disk secret = nothing to migrate. + assert!( + !needs_disk_to_keychain_migration(&ProviderChoice::MacosKeychain, dir.path()).unwrap() + ); + } +} diff --git a/rust/src/crypto/mod.rs b/rust/src/crypto/mod.rs index 48c6b5f..b0993a3 100644 --- a/rust/src/crypto/mod.rs +++ b/rust/src/crypto/mod.rs @@ -1,3 +1,4 @@ pub mod age; +pub mod keystore; pub mod secret_value; pub mod tags; diff --git a/rust/src/keyring/macos.rs b/rust/src/keyring/macos.rs index 1039247..82a4c54 100644 --- a/rust/src/keyring/macos.rs +++ b/rust/src/keyring/macos.rs @@ -1,7 +1,7 @@ use std::process::Command; use crate::error::{HimitsuError, Result}; -use crate::keyring::{KeyProvider, KEY_SERVICE, SCOPE_SERVICE}; +use crate::keyring::{KeyProvider, KEY_SERVICE}; /// macOS Keychain adapter using the `security` CLI. pub struct MacOSKeychain; @@ -69,12 +69,4 @@ impl KeyProvider for MacOSKeychain { fn load_key(&self, fingerprint: &str) -> Result> { Self::security_find(KEY_SERVICE, fingerprint) } - - fn store_scope(&self, scope: &str, fingerprint: &str) -> Result<()> { - Self::security_add(SCOPE_SERVICE, scope, fingerprint) - } - - fn load_scope(&self, scope: &str) -> Result> { - Self::security_find(SCOPE_SERVICE, scope) - } } diff --git a/rust/src/keyring/mod.rs b/rust/src/keyring/mod.rs index 52e8d36..116a575 100644 --- a/rust/src/keyring/mod.rs +++ b/rust/src/keyring/mod.rs @@ -2,7 +2,6 @@ pub mod macos; use crate::error::Result; -const SCOPE_SERVICE: &str = "io.darkmatter.himitsu.agekey.scope.v1"; const KEY_SERVICE: &str = "io.darkmatter.himitsu.agekey.byfp.v1"; /// Trait for storing and retrieving age private keys. @@ -12,21 +11,6 @@ pub trait KeyProvider { /// Load a private key from the keychain by fingerprint. fn load_key(&self, fingerprint: &str) -> Result>; - - /// Store a scope-to-fingerprint mapping. - fn store_scope(&self, scope: &str, fingerprint: &str) -> Result<()>; - - /// Look up the fingerprint for a given scope. - fn load_scope(&self, scope: &str) -> Result>; -} - -/// Produce a deterministic, collision-resistant account string for a scope. -/// Format: `gh:::` -pub fn account_for(org: &str, repo: &str, group: &str) -> String { - let org_lower = org.to_lowercase(); - let repo_lower = repo.to_lowercase(); - let group_escaped = group.replace(':', "_").to_lowercase(); - format!("gh:{org_lower}:{repo_lower}:{group_escaped}") } /// Compute a simple fingerprint of an age public key string. @@ -42,7 +26,6 @@ pub fn fingerprint(pubkey: &str) -> String { #[cfg(test)] pub struct MockKeyProvider { pub keys: std::cell::RefCell>, - pub scopes: std::cell::RefCell>, } #[cfg(test)] @@ -50,7 +33,6 @@ impl Default for MockKeyProvider { fn default() -> Self { Self { keys: std::cell::RefCell::new(std::collections::HashMap::new()), - scopes: std::cell::RefCell::new(std::collections::HashMap::new()), } } } @@ -74,92 +56,16 @@ impl KeyProvider for MockKeyProvider { fn load_key(&self, fingerprint: &str) -> Result> { Ok(self.keys.borrow().get(fingerprint).cloned()) } - - fn store_scope(&self, scope: &str, fingerprint: &str) -> Result<()> { - self.scopes - .borrow_mut() - .insert(scope.to_string(), fingerprint.to_string()); - Ok(()) - } - - fn load_scope(&self, scope: &str) -> Result> { - Ok(self.scopes.borrow().get(scope).cloned()) - } -} - -pub mod mapping { - use super::KeyProvider; - use crate::error::Result; - - /// Store a scope-to-fingerprint pointer. - pub fn store_scope_pointer( - provider: &dyn KeyProvider, - scope: &str, - fingerprint: &str, - ) -> Result<()> { - provider.store_scope(scope, fingerprint) - } - - /// Read a scope-to-fingerprint pointer. - pub fn read_scope_pointer(provider: &dyn KeyProvider, scope: &str) -> Result> { - provider.load_scope(scope) - } } #[cfg(test)] mod tests { use super::*; - #[test] - fn account_for_normalizes() { - let a = account_for("MyOrg", "MyRepo", "team"); - assert_eq!(a, "gh:myorg:myrepo:team"); - } - - #[test] - fn account_for_avoids_collisions() { - let a = account_for("org", "repo", "group"); - let b = account_for("org", "repo:group", ""); - assert_ne!(a, b); - } - - #[test] - fn account_for_escapes_colons_in_group() { - let a = account_for("org", "repo", "my:group"); - assert_eq!(a, "gh:org:repo:my_group"); - } - #[test] fn fingerprint_is_deterministic() { let fp1 = fingerprint("age1somekey"); let fp2 = fingerprint("age1somekey"); assert_eq!(fp1, fp2); } - - #[test] - fn scope_to_fingerprint_stores_and_reads_correctly() { - let provider = MockKeyProvider::new(); - let scope = account_for("org", "repo", "team"); - let fp = fingerprint("age1somekey"); - - mapping::store_scope_pointer(&provider, &scope, &fp).unwrap(); - let read_fp = mapping::read_scope_pointer(&provider, &scope).unwrap(); - - assert_eq!(read_fp, Some(fp)); - } - - #[test] - fn scope_to_fingerprint_updates_cleanly_on_key_rotation() { - let provider = MockKeyProvider::new(); - let scope = account_for("org", "repo", "team"); - - let fp1 = fingerprint("age1oldkey"); - mapping::store_scope_pointer(&provider, &scope, &fp1).unwrap(); - - let fp2 = fingerprint("age1newkey"); - mapping::store_scope_pointer(&provider, &scope, &fp2).unwrap(); - - let read_fp = mapping::read_scope_pointer(&provider, &scope).unwrap(); - assert_eq!(read_fp, Some(fp2)); - } } diff --git a/rust/src/proto/mod.rs b/rust/src/proto/mod.rs index 310cc6c..c858218 100644 --- a/rust/src/proto/mod.rs +++ b/rust/src/proto/mod.rs @@ -35,10 +35,7 @@ pub mod commands { pub use config::{CodegenLang, Config as ProtoConfig, Identity, Policy as ProtoPolicy, Remote}; -pub use secrets::{ - RecipientInfo, SecretEntry, SecretEnvelope, SecretValue, ShareEnvelope, ShareTransport, - SharedSecret, StoreManifest, SyncDisposition, SyncEntryState, SyncState, -}; +pub use secrets::{RecipientInfo, SecretEntry, SecretEnvelope, SecretValue, StoreManifest}; // ----------------------------------------------------------------------- // Enum ↔ string helpers @@ -198,43 +195,6 @@ pub fn secret_envelope_json_schema() -> serde_json::Value { }) } -/// Share envelope schema. -pub fn share_envelope_json_schema() -> serde_json::Value { - serde_json::json!({ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://himitsu.dev/schemas/share-envelope.schema.json", - "title": "Himitsu Share Envelope", - "description": "Transport-agnostic payload for sharing secrets with external recipients.", - "type": "object", - "required": ["version", "sender_public_key", "recipient_public_key", "secrets"], - "properties": { - "version": { "type": "integer", "minimum": 1 }, - "sender_public_key": { "type": "string" }, - "recipient_public_key": { "type": "string" }, - "secrets": { - "type": "array", - "items": { - "type": "object", - "required": ["key_name", "environment", "ciphertext"], - "properties": { - "key_name": { "type": "string" }, - "environment": { "type": "string" }, - "ciphertext": { "type": "string", "description": "Base64-encoded age ciphertext." }, - "plaintext_hash": { "type": "string" } - }, - "additionalProperties": false - } - }, - "created_at": { "type": "string", "format": "date-time" }, - "message": { "type": "string" }, - "transport": { "type": "string" }, - "nonce": { "type": "string" }, - "ttl_seconds": { "type": "integer", "minimum": 0 } - }, - "additionalProperties": false - }) -} - #[cfg(test)] mod tests { use super::*; @@ -274,11 +234,4 @@ mod tests { let required = schema["required"].as_array().unwrap(); assert!(required.contains(&serde_json::json!("ciphertext"))); } - - #[test] - fn share_envelope_schema_has_secrets() { - let schema = share_envelope_json_schema(); - let required = schema["required"].as_array().unwrap(); - assert!(required.contains(&serde_json::json!("secrets"))); - } } diff --git a/rust/src/tui/app.rs b/rust/src/tui/app.rs index 47d6fdd..547991a 100644 --- a/rust/src/tui/app.rs +++ b/rust/src/tui/app.rs @@ -572,6 +572,7 @@ fn clone_ctx(ctx: &Context) -> Context { state_dir: ctx.state_dir.clone(), store: ctx.store.clone(), recipients_path: ctx.recipients_path.clone(), + key_provider: ctx.key_provider.clone(), } } diff --git a/rust/src/tui/harness.rs b/rust/src/tui/harness.rs index 486188d..68393c2 100644 --- a/rust/src/tui/harness.rs +++ b/rust/src/tui/harness.rs @@ -237,6 +237,7 @@ mod tests { state_dir, store: alpha.clone(), recipients_path: None, + key_provider: crate::config::KeyProvider::default(), }; Self { diff --git a/rust/src/tui/mod.rs b/rust/src/tui/mod.rs index 8b82356..9f0a816 100644 --- a/rust/src/tui/mod.rs +++ b/rust/src/tui/mod.rs @@ -63,6 +63,7 @@ pub fn run_init_flow() -> Result<()> { let tui = Config::load(&config_path())?.tui; theme::set_theme(&tui.theme)?; icons::set_use_nerd_fonts(tui.nerd_fonts); + let keymap = tui.keys; let mut guard = Some(terminal::install()?); let mut terminal = Some(terminal::new()?); @@ -92,8 +93,9 @@ pub fn run_init_flow() -> Result<()> { state_dir: crate::config::state_dir(), store: PathBuf::new(), recipients_path: None, + key_provider: crate::config::KeyProvider::default(), }; - let result = init::run_init(args, &ctx); + let result = init::run(args, &ctx); // Resume TUI before reporting the result so the wizard can redraw. guard = Some(terminal::install()?); @@ -105,7 +107,7 @@ pub fn run_init_flow() -> Result<()> { if crossterm::event::poll(POLL_INTERVAL)? { if let Event::Key(key) = crossterm::event::read()? { if key.kind == KeyEventKind::Press { - wizard.on_key(key); + wizard.on_key(key, &keymap); } } } @@ -120,11 +122,13 @@ pub fn run_init_flow() -> Result<()> { theme::set_theme(&tui.theme)?; icons::set_use_nerd_fonts(tui.nerd_fonts); + let cfg = Config::load(&config_path()).unwrap_or_default(); let ctx = Context { data_dir: crate::config::data_dir(), state_dir: crate::config::state_dir(), store: crate::config::resolve_store(None).unwrap_or_default(), recipients_path: None, + key_provider: cfg.key_provider, }; if !should_continue_to_dashboard_after_init(&ctx.store) { return Ok(()); @@ -133,7 +137,33 @@ pub fn run_init_flow() -> Result<()> { } fn should_launch_init_flow(ctx: &Context) -> bool { - !ctx.data_dir.join("key").exists() || ctx.store.as_os_str().is_empty() + // Fire the wizard only when himitsu isn't initialized OR the user has + // zero stores registered globally. Running `himitsu` from a directory + // that doesn't have its own project store should land you on the + // dashboard with a "no project store" indicator — not bounce you into + // setup every time you cd into a new repo. + if !crate::crypto::keystore::is_initialized(&ctx.data_dir) { + return true; + } + !has_any_registered_store(&ctx.stores_dir()) +} + +fn has_any_registered_store(stores_dir: &std::path::Path) -> bool { + let Ok(orgs) = std::fs::read_dir(stores_dir) else { + return false; + }; + for org in orgs.flatten() { + let Ok(repos) = std::fs::read_dir(org.path()) else { + continue; + }; + if repos + .flatten() + .any(|e| e.file_type().is_ok_and(|t| t.is_dir())) + { + return true; + } + } + false } fn should_continue_to_dashboard_after_init(store: &std::path::Path) -> bool { @@ -146,33 +176,61 @@ mod tests { use crate::cli::Context; - fn ctx_with(data_dir: PathBuf, store: PathBuf) -> Context { + fn ctx_with_state(data_dir: PathBuf, state_dir: PathBuf, store: PathBuf) -> Context { Context { data_dir, - state_dir: PathBuf::new(), + state_dir, store, recipients_path: None, + key_provider: crate::config::KeyProvider::default(), } } #[test] - fn should_launch_init_flow_when_key_exists_but_store_is_missing() { + fn should_launch_init_flow_when_no_key() { + // No pubkey file → not initialized → wizard fires regardless of + // store state. let data_dir = tempfile::tempdir().unwrap(); - std::fs::write(data_dir.path().join("key"), "AGE-SECRET-KEY").unwrap(); - - let ctx = ctx_with(data_dir.path().to_path_buf(), PathBuf::new()); - + let state_dir = tempfile::tempdir().unwrap(); + let ctx = ctx_with_state( + data_dir.path().to_path_buf(), + state_dir.path().to_path_buf(), + PathBuf::new(), + ); assert!(super::should_launch_init_flow(&ctx)); } #[test] - fn should_not_launch_init_flow_when_key_and_store_exist() { + fn should_launch_init_flow_when_key_exists_but_no_stores_registered() { + // Initialized but `stores/` is empty (or missing) → wizard fires. let data_dir = tempfile::tempdir().unwrap(); - let store = tempfile::tempdir().unwrap(); - std::fs::write(data_dir.path().join("key"), "AGE-SECRET-KEY").unwrap(); - - let ctx = ctx_with(data_dir.path().to_path_buf(), store.path().to_path_buf()); + let state_dir = tempfile::tempdir().unwrap(); + std::fs::write(data_dir.path().join("key.pub"), "age1pub").unwrap(); + let ctx = ctx_with_state( + data_dir.path().to_path_buf(), + state_dir.path().to_path_buf(), + PathBuf::new(), + ); + assert!(super::should_launch_init_flow(&ctx)); + } + #[test] + fn should_not_launch_init_flow_when_any_store_is_registered() { + // Initialized + at least one registered store → dashboard, even if + // the resolved active store is empty (e.g. running from a project + // that hasn't been wired to a store yet — the project light just + // goes gray, no wizard). + let data_dir = tempfile::tempdir().unwrap(); + let state_dir = tempfile::tempdir().unwrap(); + std::fs::write(data_dir.path().join("key.pub"), "age1pub").unwrap(); + let stores = state_dir.path().join("stores"); + std::fs::create_dir_all(stores.join("acme/secrets")).unwrap(); + + let ctx = ctx_with_state( + data_dir.path().to_path_buf(), + state_dir.path().to_path_buf(), + PathBuf::new(), + ); assert!(!super::should_launch_init_flow(&ctx)); } diff --git a/rust/src/tui/views/command_palette.rs b/rust/src/tui/views/command_palette.rs index 5fc0c28..b6e0ae2 100644 --- a/rust/src/tui/views/command_palette.rs +++ b/rust/src/tui/views/command_palette.rs @@ -169,6 +169,10 @@ impl Command { Command::Git => Some("himitsu git -- "), } } + + pub fn is_cli_only(&self) -> bool { + self.cli_hint().is_some() + } } /// Outcome of a key press while the palette is open. @@ -224,6 +228,14 @@ const COMMANDS: &[Command] = &[ Command::Quit, ]; +fn display_label(cmd: &Command) -> String { + if cmd.is_cli_only() { + format!("{} (CLI)", cmd.label()) + } else { + cmd.label().to_string() + } +} + impl CommandPalette { pub fn new() -> Self { let mut palette = Self { @@ -366,7 +378,7 @@ impl CommandPalette { let label_w = self .filtered .iter() - .map(|c| c.label().len()) + .map(|c| display_label(c).len()) .max() .unwrap_or(0); @@ -374,6 +386,14 @@ impl CommandPalette { .filtered .iter() .map(|cmd| { + let label = display_label(cmd); + let label_style = if cmd.is_cli_only() { + Style::default() + .fg(theme::muted()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().add_modifier(Modifier::BOLD) + }; let line = Line::from(vec![ Span::raw(" "), Span::styled( @@ -381,10 +401,7 @@ impl CommandPalette { Style::default().fg(theme::accent()), ), Span::raw(" "), - Span::styled( - format!("{: Context { state_dir: ctx.state_dir.clone(), store: ctx.store.clone(), recipients_path: ctx.recipients_path.clone(), + key_provider: ctx.key_provider.clone(), } } @@ -1569,10 +1572,11 @@ impl EnvsView { &[ ("↑/↓ / j/k", "navigate labels"), ("n", "create a new env (form mode)"), - ("e", "edit selected env (form mode)"), + ("e", "edit single-entry env (DSL editor for multi/tag)"), ("y", "open YAML/DSL 2-pane editor"), ("d", "delete selected env (confirm y/N)"), ("ctrl-s", "save while editing"), + ("ctrl-w", "save while editing"), ("ctrl-space", "autocomplete in DSL editor"), ("?", "toggle this help"), ("esc / q", "back to search"), @@ -1642,6 +1646,7 @@ mod tests { state_dir: PathBuf::new(), store: store.to_path_buf(), recipients_path: None, + key_provider: crate::config::KeyProvider::default(), } } diff --git a/rust/src/tui/views/init_wizard.rs b/rust/src/tui/views/init_wizard.rs index d9c6ed4..5f2a4de 100644 --- a/rust/src/tui/views/init_wizard.rs +++ b/rust/src/tui/views/init_wizard.rs @@ -1,6 +1,6 @@ //! 3-step init wizard: data directory → remote store → key provider. -use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent}; use ratatui::layout::{Alignment, Constraint, Direction, Layout}; use ratatui::style::{Modifier, Style}; @@ -14,6 +14,7 @@ use ratatui::Frame; use crate::cli::init::{self, InitArgs}; use crate::config::{self, KeyProvider}; use crate::error::Result; +use crate::tui::keymap::{KeyAction, KeyMap}; pub enum Outcome { Pending, @@ -71,11 +72,16 @@ impl InitWizardView { .ok() .and_then(|cwd| config::find_git_root(&cwd)); + let project_remote_input = git_root + .as_deref() + .map(init::suggested_project_slug_for) + .unwrap_or_default(); + Self { step: Step::DataDir, data_dir_input: config::data_dir().to_string_lossy().into_owned(), global_remote_input: init::suggested_remote_slug(), - project_remote_input: init::suggested_project_slug(), + project_remote_input, has_existing_global, git_root, provider_options, @@ -192,13 +198,17 @@ impl InitWizardView { } } - pub fn on_key(&mut self, key: KeyEvent) { - if matches!( - (key.code, key.modifiers), - (KeyCode::Char('c'), KeyModifiers::CONTROL) - ) { - self.outcome = Outcome::Aborted; - return; + pub fn on_key(&mut self, key: KeyEvent, keymap: &KeyMap) { + match keymap.action_for_key_in(&key, &[KeyAction::Quit, KeyAction::Cancel]) { + Some(KeyAction::Quit) => { + self.outcome = Outcome::Aborted; + return; + } + Some(KeyAction::Cancel) => { + self.cancel_current_step(); + return; + } + _ => {} } // Reset transient error on any keystroke that could advance. @@ -218,7 +228,6 @@ impl InitWizardView { } } } - KeyCode::Esc => self.outcome = Outcome::Aborted, KeyCode::Backspace => { self.data_dir_input.pop(); } @@ -239,7 +248,6 @@ impl InitWizardView { self.begin_init(); } } - KeyCode::Esc => self.step = Step::DataDir, KeyCode::Backspace => { self.global_remote_input.pop(); } @@ -261,13 +269,6 @@ impl InitWizardView { self.begin_init(); } } - KeyCode::Esc => { - self.step = if !self.has_existing_global { - Step::RemoteGlobal - } else { - Step::DataDir - }; - } KeyCode::Backspace => { self.project_remote_input.pop(); } @@ -284,47 +285,48 @@ impl InitWizardView { self.provider_index += 1; } KeyCode::Enter => self.begin_init(), - KeyCode::Esc => { - self.step = if self.git_root.is_some() { - Step::RemoteProject - } else if !self.has_existing_global { - Step::RemoteGlobal - } else { - Step::DataDir - }; - } _ => {} }, Step::Running => {} - Step::Success => match key.code { - KeyCode::Enter => self.outcome = Outcome::Completed, - KeyCode::Esc => self.outcome = Outcome::Aborted, - _ => {} - }, + Step::Success => { + if key.code == KeyCode::Enter { + self.outcome = Outcome::Completed; + } + } } } - fn begin_init(&mut self) { - // Persist a custom data_dir override before re-deriving paths. Matches - // the `--home` branch of `init::run` so subsequent calls to - // `config::data_dir()` pick up the new location. - let home = self.data_dir_input.trim().to_string(); - if home != config::data_dir().to_string_lossy() { - let cfg_path = config::config_path(); - match config::Config::load(&cfg_path) { - Ok(mut cfg) => { - cfg.data_dir = Some(home); - if let Err(e) = cfg.save(&cfg_path) { - self.error = Some(format!("failed to save config: {e}")); - return; - } - } - Err(e) => { - self.error = Some(format!("failed to load config: {e}")); - return; - } + fn cancel_current_step(&mut self) { + match self.step { + Step::DataDir | Step::Success => self.outcome = Outcome::Aborted, + Step::RemoteGlobal => self.step = Step::DataDir, + Step::RemoteProject => { + self.step = if !self.has_existing_global { + Step::RemoteGlobal + } else { + Step::DataDir + }; + } + Step::Provider => { + self.step = if self.git_root.is_some() { + Step::RemoteProject + } else if !self.has_existing_global { + Step::RemoteGlobal + } else { + Step::DataDir + }; } + Step::Running => {} } + } + + fn begin_init(&mut self) { + let home = self.data_dir_input.trim().to_string(); + let home = if home != config::data_dir().to_string_lossy() { + Some(home) + } else { + None + }; let provider = self .provider_options @@ -362,7 +364,7 @@ impl InitWizardView { json: false, name, url: None, - home: None, + home, key_provider, no_tui: true, project, diff --git a/rust/src/tui/views/new_secret.rs b/rust/src/tui/views/new_secret.rs index e1ccc94..1c8674c 100644 --- a/rust/src/tui/views/new_secret.rs +++ b/rust/src/tui/views/new_secret.rs @@ -179,6 +179,7 @@ pub struct NewSecretView { env_key: String, expires_at: String, status: Option, + path_corpus: Vec, /// `Some(focused_button)` while the unsaved-changes modal is up. /// `None` means the modal is closed and the form behaves normally. confirm_exit: Option, @@ -187,6 +188,11 @@ pub struct NewSecretView { impl NewSecretView { pub fn new(ctx: &Context) -> Self { + let path_corpus = if ctx.store.as_os_str().is_empty() { + Vec::new() + } else { + store::list_secrets(&ctx.store, None).unwrap_or_default() + }; Self { step: Step::Path, path: String::new(), @@ -198,6 +204,7 @@ impl NewSecretView { env_key: String::new(), expires_at: String::new(), status: None, + path_corpus, confirm_exit: None, ctx: ctx.clone(), } @@ -698,14 +705,7 @@ impl NewSecretView { .split(area); self.draw_header(frame, chunks[0]); - self.draw_single_line( - frame, - chunks[1], - Step::Path, - " path ", - &self.path, - "prod/api/STRIPE_KEY", - ); + self.draw_path_field(frame, chunks[1]); self.draw_value_field(frame, chunks[2]); self.draw_single_line( frame, @@ -793,6 +793,38 @@ impl NewSecretView { frame.render_widget(para, area); } + fn draw_path_field(&self, frame: &mut Frame<'_>, area: Rect) { + let focused = self.step == Step::Path; + let block = Block::default() + .borders(Borders::ALL) + .title(" path ") + .title_style(Style::default().fg(theme::border_label())) + .border_style(Self::border_style(focused)); + + let para = if focused && !self.path.is_empty() { + let mut spans = vec![Span::raw(self.path.clone())]; + if let Some(suffix) = self.path_completion_suffix() { + spans.push(Span::styled(suffix, Style::default().fg(theme::muted()))); + } + spans.push(Span::raw("_")); + Paragraph::new(Line::from(spans)) + } else { + Self::field_paragraph(&self.path, "prod/api/STRIPE_KEY", focused) + } + .block(block); + frame.render_widget(para, area); + } + + fn path_completion_suffix(&self) -> Option { + if self.path.is_empty() { + return None; + } + self.path_corpus + .iter() + .find(|candidate| candidate.starts_with(&self.path) && candidate.as_str() != self.path) + .map(|candidate| candidate[self.path.len()..].to_string()) + } + fn draw_value_field(&self, frame: &mut Frame<'_>, area: Rect) { let focused = self.step == Step::Value; let block = Block::default() @@ -1008,6 +1040,7 @@ mod tests { state_dir: PathBuf::new(), store: PathBuf::new(), recipients_path: None, + key_provider: crate::config::KeyProvider::default(), } } @@ -1048,6 +1081,18 @@ mod tests { assert_eq!(view.path(), "prod/API_KEY"); } + #[test] + fn path_completion_suffix_uses_first_existing_prefix_match() { + let mut view = NewSecretView::new(&empty_ctx()); + view.path_corpus = vec![ + "foo/bar/baz".to_string(), + "foo/qux".to_string(), + "prod/API_KEY".to_string(), + ]; + typ(&mut view, "fo"); + assert_eq!(view.path_completion_suffix(), Some("o/bar/baz".to_string())); + } + #[test] fn enter_on_empty_path_is_rejected_with_status() { let km = KeyMap::default(); diff --git a/rust/src/tui/views/remote_add.rs b/rust/src/tui/views/remote_add.rs index e36970d..f68a14a 100644 --- a/rust/src/tui/views/remote_add.rs +++ b/rust/src/tui/views/remote_add.rs @@ -160,6 +160,7 @@ mod tests { state_dir: PathBuf::new(), store: PathBuf::new(), recipients_path: None, + key_provider: crate::config::KeyProvider::default(), } } diff --git a/rust/src/tui/views/search.rs b/rust/src/tui/views/search.rs index 290eb3b..f601d93 100644 --- a/rust/src/tui/views/search.rs +++ b/rust/src/tui/views/search.rs @@ -148,8 +148,15 @@ pub struct SearchView { /// key just like the store picker. Mutually exclusive with `picker` /// because both are modal popups. palette: Option, - /// Health of the active store's git checkout, checked once at startup. - store_health: StoreHealth, + /// Health of the global store, computed once at startup. + global_health: StoreHealth, + /// Health of the project store (the store referenced by `default_store` + /// in the current repo's `himitsu.yaml`). `None` when there's no git + /// repo / no project config / the project's `default_store` doesn't + /// resolve to a registered checkout — rendered as a gray "no project + /// store" indicator so users see at a glance whether they need to wire + /// the current repo up. + project_health: Option, /// Whether to render the STORE column in the results table. Off by /// default — most users work in a single store at a time, so the /// column is dead weight. Toggled via the command palette @@ -161,6 +168,9 @@ pub struct SearchView { /// once at view-construction time from the project + global configs. /// Used to render the ENVS column in the results table. env_index: std::collections::HashMap>, + /// Active tag filters selected from result-row tag chips. AND semantics, + /// matching CLI `search --tag`. + tag_filters: Vec, /// When true, multi-leaf top-level prefix groups collapse to a single /// `FoldedGroup` row. Toggled with Tab. Singleton paths render the same /// in both states. Default: unfolded. @@ -185,8 +195,9 @@ impl SearchView { state_dir: ctx.state_dir.clone(), store: ctx.store.clone(), recipients_path: ctx.recipients_path.clone(), + key_provider: ctx.key_provider.clone(), }; - let store_health = check_store_health(&ctx_owned); + let (global_health, project_health) = check_store_health_pair(&ctx_owned); let env_index = build_env_index(); let mut view = Self { query: String::new(), @@ -196,9 +207,11 @@ impl SearchView { ctx: ctx_owned, picker: None, palette: None, - store_health, + global_health, + project_health, show_store_column: false, env_index, + tag_filters: Vec::new(), folded: false, autocomplete: SecretRefAutocomplete::new(Vec::new()), }; @@ -256,6 +269,9 @@ impl SearchView { self.autocomplete.set_open(want_open); return SearchAction::None; } + if matches!(key.code, KeyCode::Char('t')) && key.modifiers.contains(KeyModifiers::CONTROL) { + return self.refine_to_selected_tag(); + } // Esc closes the popup before falling through to the view's own // cancel/quit semantics. if key.code == KeyCode::Esc && self.autocomplete.is_open() { @@ -306,7 +322,9 @@ impl SearchView { SearchAction::None } (KeyCode::Backspace, _) => { - if self.query.pop().is_some() { + let changed = self.query.pop().is_some() + || (self.query.is_empty() && self.tag_filters.pop().is_some()); + if changed { self.refresh_results(); } SearchAction::None @@ -388,9 +406,7 @@ impl SearchView { } fn refresh_results(&mut self) { - // Pass an empty tag filter; the TUI handles tag chips/filtering in a - // separate worker so this view always asks for everything. - self.results = search_core(&self.ctx, &self.query, &[]).unwrap_or_default(); + self.results = search_core(&self.ctx, &self.query, &self.tag_filters).unwrap_or_default(); self.rows = build_rows(&self.results, self.folded); self.list_state.select(self.first_selectable()); // Keep the autocomplete corpus aligned with what the user could @@ -402,6 +418,22 @@ impl SearchView { self.autocomplete.update_query(&self.query); } + fn refine_to_selected_tag(&mut self) -> SearchAction { + let Some(tag) = self + .selected_result() + .and_then(|r| r.tags.as_ref()) + .and_then(|tags| tags.first()) + .cloned() + else { + return SearchAction::CommandHint("selected result has no tag chip".into()); + }; + if !self.tag_filters.iter().any(|existing| existing == &tag) { + self.tag_filters.push(tag.clone()); + self.refresh_results(); + } + SearchAction::CommandHint(format!("filtering by tag:{tag}")) + } + fn toggle_fold(&mut self) { // Remember the prefix or path under the cursor so we can re-anchor // the selection after rebuilding rows. Otherwise the cursor would @@ -625,7 +657,9 @@ impl SearchView { match rekey::rekey_store(&self.ctx, None) { Ok(n) => { self.refresh_results(); - self.store_health = check_store_health(&self.ctx); + let (g, p) = check_store_health_pair(&self.ctx); + self.global_health = g; + self.project_health = p; SearchAction::Synced(format!("pulled, {n} secret(s) rekeyed")) } Err(e) => SearchAction::CommandFailed(format!("sync rekey failed: {e}")), @@ -666,7 +700,9 @@ impl SearchView { ) { Ok(()) => { self.ctx.commit_and_push("himitsu: join"); - self.store_health = check_store_health(&self.ctx); + let (g, p) = check_store_health_pair(&self.ctx); + self.global_health = g; + self.project_health = p; SearchAction::Joined("joined as recipient".into()) } Err(e) => SearchAction::CommandFailed(format!("join failed: {e}")), @@ -674,33 +710,18 @@ impl SearchView { } fn draw_header(&self, frame: &mut Frame<'_>, area: Rect) { - let (health_label, health_color) = match &self.store_health { - StoreHealth::Synced => ("synced".to_string(), theme::success()), - StoreHealth::Behind(n) => (format!("{n} behind remote"), theme::warning()), - StoreHealth::Dirty => ("uncommitted changes".to_string(), theme::danger()), - StoreHealth::BehindAndDirty(n) => (format!("{n} behind + dirty"), theme::danger()), - StoreHealth::NotGit => ("not a git repo".to_string(), theme::warning()), - StoreHealth::NoRemote => ( - "no remote — run: himitsu remote add".to_string(), - theme::warning(), - ), - StoreHealth::NotPushed => ( - "not pushed — run: himitsu git push -u origin main".to_string(), - theme::warning(), - ), - StoreHealth::NotRecipient => ( - "not a recipient — run: himitsu join".to_string(), - theme::warning(), - ), - StoreHealth::Unknown => ("unknown".to_string(), theme::muted()), - }; + let global_pill = render_health_pill("global", Some(&self.global_health)); + let project_pill = render_health_pill("project", self.project_health.as_ref()); + + // Right column has to fit both pills side-by-side, separated by two + // spaces. Length comes from the rendered span widths so a long + // message like "not pushed — run: himitsu git push -u origin main" + // doesn't get truncated. + let right_width = (span_width(&global_pill) + 2 + span_width(&project_pill)) as u16; let cols = Layout::default() .direction(Direction::Horizontal) - .constraints([ - Constraint::Min(20), - Constraint::Length((health_label.len() as u16).saturating_add(4)), - ]) + .constraints([Constraint::Min(20), Constraint::Length(right_width)]) .split(area); // Left: brand chip + active view name. The chip carries the project's @@ -713,27 +734,12 @@ impl SearchView { )); frame.render_widget(Paragraph::new(Line::from(left_spans)), cols[0]); - // Right: store health indicator, right-aligned within the second - // column. The healthy steady-state (`Synced`) renders as a quiet - // colored dot + label on the default background — we don't want a - // bright green pill screaming at the user when nothing is wrong. - // Every other state still uses the colored pill so problems remain - // visually loud. - let right_spans = if matches!(self.store_health, StoreHealth::Synced) { - vec![ - Span::styled(icons::health(), Style::default().fg(health_color)), - Span::raw(" "), - Span::styled(health_label, Style::default().fg(health_color)), - ] - } else { - theme::pill_with( - format!("{} {health_label}", icons::health()), - health_color, - theme::on_accent(), - ) - }; + // Right: two health pills (global, project) right-aligned together. + let mut right = global_pill; + right.push(Span::raw(" ")); + right.extend(project_pill); frame.render_widget( - Paragraph::new(Line::from(right_spans)).alignment(Alignment::Right), + Paragraph::new(Line::from(right)).alignment(Alignment::Right), cols[1], ); } @@ -753,10 +759,18 @@ impl SearchView { )) .right_aligned(), ); - let text = Line::from(vec![ - Span::raw(&self.query), - Span::styled("█", Style::default().fg(theme::accent())), - ]); + let mut spans = Vec::new(); + for tag in &self.tag_filters { + spans.extend(theme::pill_with( + format!("tag:{tag}"), + theme::accent(), + theme::on_accent(), + )); + spans.push(Span::raw(" ")); + } + spans.push(Span::raw(&self.query)); + spans.push(Span::styled("█", Style::default().fg(theme::accent()))); + let text = Line::from(spans); frame.render_widget(Paragraph::new(text).block(block), area); } @@ -916,11 +930,13 @@ impl SearchView { } frame.render_widget(Paragraph::new(Line::from(header_spans)), chunks[0]); + let selected_row = self.list_state.selected(); let items: Vec = self .rows .iter() .zip(row_data.iter()) - .map(|(row, data)| match row { + .enumerate() + .map(|(idx, (row, data))| match row { Row::Store { name, count } => { let line = Line::from(vec![ Span::styled( @@ -1006,7 +1022,14 @@ impl SearchView { Style::default().fg(theme::accent()), )); } - ListItem::new(Line::from(spans)) + let mut lines = vec![Line::from(spans)]; + if selected_row == Some(idx) && !cells.desc.is_empty() { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(cells.desc.clone(), Style::default().fg(theme::muted())), + ])); + } + ListItem::new(lines) } }) .collect(); @@ -1202,7 +1225,7 @@ fn decrypt_value(ctx: &Context, result: &SearchResult) -> crate::error::Result crate::error::Result (StoreHealth, Option) { + let global_health = check_store_health(ctx); + + let project_health = match resolve_project_store(ctx) { + Some(project_store) => { + let mut project_ctx = ctx.clone(); + project_ctx.store = project_store; + Some(check_store_health(&project_ctx)) + } + None => None, + }; + (global_health, project_health) +} + +/// Find the project store referenced by the current repo's `himitsu.yaml`, +/// if any. Returns `None` when there's no project config, no `default_store` +/// in it, or the slug doesn't resolve to an existing checkout under +/// `stores_dir`. +fn resolve_project_store(ctx: &Context) -> Option { + let (project_cfg, _) = crate::config::load_project_config()?; + let slug = project_cfg.default_store?; + let (org, repo) = crate::config::validate_remote_slug(&slug).ok()?; + let candidate = ctx.stores_dir().join(org).join(repo); + candidate.exists().then_some(candidate) +} + +/// Render a labelled health pill: `