From 9fe54caee0cf2fca28e6837fc16ec142ca9cc339 Mon Sep 17 00:00:00 2001 From: Nathan Huh Date: Thu, 18 Jun 2026 15:26:09 +0900 Subject: [PATCH 1/2] docs: add repo-local Codex skills --- .../skills/unic-update-dependencies/SKILL.md | 72 ++++++ .../agents/openai.yaml | 4 + .agents/skills/unic-ux-review/SKILL.md | 101 ++++++++ .../skills/unic-ux-review/agents/openai.yaml | 4 + .../scripts/capture_unic_tui.py | 240 ++++++++++++++++++ 5 files changed, 421 insertions(+) create mode 100644 .agents/skills/unic-update-dependencies/SKILL.md create mode 100644 .agents/skills/unic-update-dependencies/agents/openai.yaml create mode 100644 .agents/skills/unic-ux-review/SKILL.md create mode 100644 .agents/skills/unic-ux-review/agents/openai.yaml create mode 100644 .agents/skills/unic-ux-review/scripts/capture_unic_tui.py diff --git a/.agents/skills/unic-update-dependencies/SKILL.md b/.agents/skills/unic-update-dependencies/SKILL.md new file mode 100644 index 0000000..0390676 --- /dev/null +++ b/.agents/skills/unic-update-dependencies/SKILL.md @@ -0,0 +1,72 @@ +--- +name: unic-update-dependencies +description: Use when the user asks to update, bump, refresh, audit, or modernize dependencies, libraries, Go modules, the Go language version, or Go toolchain support in the unic repository. Use for dependency maintenance PRs, safe patch/minor Go module updates, Go version bumps, vulnerability-driven dependency updates, or CI/doc updates tied to dependency or Go version changes. Do not use for unrelated feature work, docs-only edits, or CI refactors unless they are required by the dependency/toolchain update. +--- + +Update `unic` dependencies and Go toolchain support with conservative scope, +clear validation, and repository workflow alignment. + +## Workflow + +1. Resolve intent and risk. +- Treat "safe", "routine", or unspecified dependency updates as patch/minor + updates only. +- Treat major version upgrades as opt-in. Do not apply them unless the user + explicitly asks for major upgrades or a specific module version. +- Separate Go language/toolchain version bumps from dependency refreshes unless + the user asks for both. +- If the user asks for "latest Go", verify the current stable Go release from + official Go sources before editing. + +2. Inspect repository state before editing. +- Check `git status --short --branch`. +- Read `go.mod` and identify the current `go` and `toolchain` directives. +- Search for Go version pins in `.github/`, `Makefile`, `README.md`, + `Dockerfile*`, `.go-version`, `.tool-versions`, and scripts. +- Inspect available module updates with `go list -m -u all`. +- If network access blocks Go module or release checks, request escalation + instead of guessing. + +3. Plan the update. +- For patch-only refreshes, prefer `go get -u=patch ./...`. +- For safe minor refreshes, prefer explicit updates from `go list -m -u all` + or `go get -u ./...`, then review the diff. +- For targeted updates, use `go get @` and keep unrelated + modules unchanged where practical. +- For vulnerability work, update the minimum affected modules needed to resolve + the advisory, then run vulnerability checks if the toolchain is available. +- For Go version bumps, update every repo-controlled Go version pin needed for + local build, CI, and docs consistency. + +4. Apply changes carefully. +- Run the chosen `go get` command. +- Run `go mod tidy`. +- Review `git diff -- go.mod go.sum` before broadening scope. +- Avoid unrelated formatting, generated churn, or feature refactors. +- Update `README.md` or CI/docs only when supported Go version or setup + instructions changed. + +5. Validate. +- Run `make test`. +- Run `make build`. +- If validation fails because dependencies changed, fix the failure or revert + the offending dependency update while preserving unrelated user changes. +- If validation cannot be run, report the exact blocker. + +6. Ship only when requested. +- Follow the repo rule: issue -> implementation -> feature branch -> PR. +- If the user asks to open a PR and no issue is referenced, search open issues + first. If none matches, call out that a new issue is needed before shipping + unless the user explicitly approves an exception. +- Use the `unic-ship-pr` workflow for staging, committing, pushing, and opening + the PR. + +## Output + +Report: +- Go version changes, if any. +- Direct and notable transitive dependency updates. +- Major updates intentionally skipped. +- Files changed. +- Validation results for `make test` and `make build`. +- Any follow-up needed for major upgrades, vulnerability checks, or PR shipping. diff --git a/.agents/skills/unic-update-dependencies/agents/openai.yaml b/.agents/skills/unic-update-dependencies/agents/openai.yaml new file mode 100644 index 0000000..a41d683 --- /dev/null +++ b/.agents/skills/unic-update-dependencies/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Update unic dependencies" + short_description: "Refresh Go toolchain and dependencies for unic." + default_prompt: "Use $unic-update-dependencies to update the Go version and refresh safe dependencies." diff --git a/.agents/skills/unic-ux-review/SKILL.md b/.agents/skills/unic-ux-review/SKILL.md new file mode 100644 index 0000000..57fce03 --- /dev/null +++ b/.agents/skills/unic-ux-review/SKILL.md @@ -0,0 +1,101 @@ +--- +name: unic-ux-review +description: Use when the user asks to run unic locally, capture TUI screenshots, review the Bubble Tea UX, suggest UX improvements, or optionally implement scoped UX polish in the unic repository. This skill is for terminal UI review and safe UX iteration, not for unrelated feature work, dependency updates, PR shipping, or non-UX code review. +--- + +# unic UX Review + +Review `unic` by running the real TUI, capturing reproducible screenshots, and +turning the evidence into prioritized UX improvements. Implement only when the +user asks for changes or clearly invites follow-through. + +## Workflow + +1. Start with repo safety. +- Run `git status --short --branch`. +- Note existing untracked or modified files and do not touch unrelated work. +- If implementation will become PR work and no GitHub issue is referenced, + call out the repo rule that new work should map to an issue before shipping. + +2. Capture the current UI. +- Prefer the bundled harness: + `python3 .agents/skills/unic-ux-review/scripts/capture_unic_tui.py` +- The harness builds `./unic`, creates an isolated XDG config with fake + contexts, disables EC2 metadata, seeds the update cache, launches the real TUI + in `tmux`, captures the context picker and service picker, renders PNGs when + Pillow is available, and kills the `tmux` session. +- If the command fails with a `tmux` socket or sandbox permission error, rerun + the same command with escalated permissions. Do not work around this by using + the user's real config. +- Use `--out-dir /private/tmp/unic-ux-review` when you want predictable output. +- Use `--skip-build` only after confirming a fresh `./unic` binary exists. + +3. Inspect the artifacts. +- Open the generated PNGs with `view_image`. +- Also inspect the raw `.txt` captures when alignment, wrapping, or truncation + needs exact terminal text. +- Keep screenshot paths in the final answer so the user can open them. + +4. Review UX with a TUI-specific rubric. +- Workflow clarity: does the first screen explain where the user is and what + action is expected? +- Layout responsiveness: do panels use the available terminal width without + creating awkward empty regions? +- Text fit: do status bars, help bars, table cells, and labels wrap or truncate + in avoidable ways at common sizes? +- Visual hierarchy: can the user scan title, current context, selected row, + metadata, and available actions in that order? +- Semantic consistency: do symbols such as `*`, cursor markers, colors, and + key names mean the same thing across screens? +- Keyboard discoverability: are frequent actions visible without making the + help bar noisy or wrapped? +- Existing style fit: preserve `unic`'s current Bubble Tea/Lip Gloss patterns, + column-aligned tables, dim labels, and compact terminal-first density. + +5. Suggest before changing when scope is broad. +- Lead with concrete findings tied to screenshot evidence. +- Prefer small, testable improvements such as table width allocation, help-bar + shortening, consistent markers, responsive panel widths, and clearer labels. +- If the user asked to "maybe implement", choose only low-risk improvements + that are directly visible in the captured screens. Ask before larger redesigns + or behavior changes. + +6. Implement scoped improvements when appropriate. +- Read the owning files before editing, usually `internal/app/styles.go`, + `internal/app/app.go`, `internal/app/screen_context.go`, + `internal/app/context_table.go`, and nearby tests. +- Preserve existing Bubble Tea navigation and `visibleLines := max(m.height-N, + 5)` style windowing where applicable. +- Add or update tests for formatting helpers, view rendering, and key UX + invariants when possible. +- Run `make test` and `make build` after behavior or rendering changes. +- Rerun the capture harness after edits and compare screenshots. +- Update `README.md` only if visible behavior, key bindings, or configuration + semantics changed. + +## Blocked Capture Fallback + +If the real TUI cannot be launched in the current environment: + +- Capture the exact failure and say why it blocks screenshot generation. +- Devise a plan using the same isolated-config approach on the user's machine. +- If useful, add or propose a test-only render harness that instantiates + `app.Model` with fixture config and writes `View()` output at fixed sizes. +- Do not claim UX findings from imagined screenshots; separate hypotheses from + observed evidence. + +## Output Shape + +For review-only work, report: + +- screenshot paths +- 3-7 prioritized findings +- suggested implementation order +- any capture limitations + +For implemented work, report: + +- what changed +- before/after screenshot paths or a concise visual comparison +- tests and build commands run +- remaining UX follow-ups diff --git a/.agents/skills/unic-ux-review/agents/openai.yaml b/.agents/skills/unic-ux-review/agents/openai.yaml new file mode 100644 index 0000000..795e4b8 --- /dev/null +++ b/.agents/skills/unic-ux-review/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Review unic UX" + short_description: "Capture unic TUI screenshots and turn them into UX improvements." + default_prompt: "Use $unic-ux-review to run unic locally, capture TUI screenshots, review UX issues, and optionally implement scoped improvements." diff --git a/.agents/skills/unic-ux-review/scripts/capture_unic_tui.py b/.agents/skills/unic-ux-review/scripts/capture_unic_tui.py new file mode 100644 index 0000000..e8c3958 --- /dev/null +++ b/.agents/skills/unic-ux-review/scripts/capture_unic_tui.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Capture reproducible unic TUI screenshots with an isolated config.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path +import shutil +import shlex +import subprocess +import sys +import tempfile +import time + + +WIDTH = 120 +HEIGHT = 36 + + +CONFIG_YAML = """current: dev-sandbox + +defaults: + region: ap-northeast-2 + +favorites: + services: + - ECS + - EKS + - RDS + +ui: + boot_splash: false + last_boot_splash_version: 0.1.3 + +contexts: + - name: dev-sandbox + order: 10 + profile: dev-sandbox + region: ap-northeast-2 + auth_type: credential + + - name: prod-readonly + order: 20 + profile: prod-readonly + region: us-east-1 + auth_type: assume_role + role_arn: arn:aws:iam::123456789012:role/ReadOnly + + - name: platform-sso + order: 30 + profile: platform-sso + region: ap-northeast-2 + auth_type: sso + sso_start_url: https://example.awsapps.com/start + sso_account_id: "123456789012" + sso_role_name: DeveloperRole +""" + + +def repo_root_from_script() -> Path: + return Path(__file__).resolve().parents[4] + + +def run(cmd: list[str], cwd: Path, *, capture: bool = False) -> subprocess.CompletedProcess[str]: + return subprocess.run( + cmd, + cwd=str(cwd), + text=True, + stdout=subprocess.PIPE if capture else None, + stderr=subprocess.PIPE if capture else None, + check=True, + ) + + +def write_fixture_config(config_root: Path) -> None: + unic_dir = config_root / "unic" + unic_dir.mkdir(parents=True, exist_ok=True) + (unic_dir / "config.yaml").write_text(CONFIG_YAML, encoding="utf-8") + cache = {"version": "0.1.3", "checked_at": "2999-01-01T00:00:00Z"} + (unic_dir / "update-check.json").write_text(json.dumps(cache), encoding="utf-8") + + +def wait_for_capture(session: str, repo_root: Path, expected: str, timeout: float = 5.0) -> str: + deadline = time.time() + timeout + last = "" + while time.time() < deadline: + last = capture_pane(session, repo_root) + if expected in last: + return last + time.sleep(0.15) + return last + + +def capture_pane(session: str, repo_root: Path) -> str: + result = run(["tmux", "capture-pane", "-pt", session, "-J"], repo_root, capture=True) + return result.stdout + + +def render_png(src: Path, dest: Path, width: int, height: int) -> bool: + try: + from PIL import Image, ImageDraw, ImageFont + except Exception: + return False + + font_candidates = [ + "/System/Library/Fonts/SFNSMono.ttf", + "/System/Library/Fonts/Menlo.ttc", + "/System/Library/Fonts/Supplemental/Andale Mono.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "/usr/share/fonts/dejavu-sans-mono-fonts/DejaVuSansMono.ttf", + ] + font_path = next((p for p in font_candidates if Path(p).exists()), None) + if font_path is None: + return False + + font = ImageFont.truetype(font_path, 18) + cell_w = int(round(font.getlength("M"))) + bbox = font.getbbox("Ag") + line_h = int((bbox[3] - bbox[1]) * 1.45) + pad_x = 20 + pad_y = 18 + + colors = { + "bg": (18, 22, 27), + "bar": (43, 48, 56), + "text": (220, 225, 232), + "dim": (142, 150, 160), + "accent": (109, 211, 255), + "selected": (255, 214, 102), + "border": (105, 113, 124), + "green": (103, 232, 153), + } + + lines = src.read_text(encoding="utf-8", errors="replace").splitlines() + lines = (lines + [""] * height)[:height] + img = Image.new("RGB", (width * cell_w + pad_x * 2, height * line_h + pad_y * 2), colors["bg"]) + draw = ImageDraw.Draw(img) + + def line_color(raw: str, index: int) -> tuple[int, int, int]: + stripped = raw.strip() + if stripped in {"Select Context", "Select AWS Service"}: + return colors["accent"] + if stripped.startswith(">") or stripped.startswith("│ >"): + return colors["selected"] + if raw.startswith("╭") or raw.startswith("╰") or raw.startswith("│"): + return colors["border"] + if "*" in raw and any(name in raw for name in ("ECS", "EKS", "RDS")): + return colors["green"] + if "favorites first" in raw or "No contexts" in raw: + return colors["dim"] + return colors["text"] + + for index, line in enumerate(lines): + y = pad_y + index * line_h + stripped = line.strip() + if (index == 0 and stripped.startswith("[")) or "↑/↓" in line or stripped.startswith("esc:"): + draw.rectangle((0, y - 2, img.width, y + line_h - 2), fill=colors["bar"]) + draw.text((pad_x, y), line, font=font, fill=line_color(line, index)) + + img.save(dest) + return True + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo-root", type=Path, default=repo_root_from_script()) + parser.add_argument("--out-dir", type=Path, default=None) + parser.add_argument("--width", type=int, default=WIDTH) + parser.add_argument("--height", type=int, default=HEIGHT) + parser.add_argument("--skip-build", action="store_true") + parser.add_argument("--keep-session", action="store_true") + args = parser.parse_args() + + repo_root = args.repo_root.resolve() + out_dir = args.out_dir or Path(tempfile.mkdtemp(prefix="unic-ux-review-", dir=tempfile.gettempdir())) + out_dir.mkdir(parents=True, exist_ok=True) + config_root = out_dir / "xdg-config" + write_fixture_config(config_root) + + if shutil.which("tmux") is None: + print("tmux is required for real TUI capture", file=sys.stderr) + return 2 + + if not args.skip_build: + run(["make", "build"], repo_root) + + binary = repo_root / "unic" + if not binary.exists(): + print(f"missing binary: {binary}", file=sys.stderr) + return 2 + + session = f"unic_ux_{os.getpid()}" + command = " ".join( + [ + "TERM=xterm-256color", + f"XDG_CONFIG_HOME={shlex.quote(str(config_root))}", + "AWS_EC2_METADATA_DISABLED=true", + shlex.quote(str(binary)), + ] + ) + + created = False + outputs: list[Path] = [] + try: + run( + ["tmux", "new-session", "-d", "-s", session, "-x", str(args.width), "-y", str(args.height), command], + repo_root, + ) + created = True + + context_text = wait_for_capture(session, repo_root, "Select Context") + context_path = out_dir / "unic-context.txt" + context_path.write_text(context_text, encoding="utf-8") + outputs.append(context_path) + + run(["tmux", "send-keys", "-t", session, "Escape"], repo_root) + service_text = wait_for_capture(session, repo_root, "Select AWS Service") + service_path = out_dir / "unic-service.txt" + service_path.write_text(service_text, encoding="utf-8") + outputs.append(service_path) + + for txt_path in (context_path, service_path): + png_path = txt_path.with_suffix(".png") + if render_png(txt_path, png_path, args.width, args.height): + outputs.append(png_path) + + finally: + if created and not args.keep_session: + subprocess.run(["tmux", "kill-session", "-t", session], cwd=str(repo_root), check=False) + + print(f"out_dir={out_dir}") + for path in outputs: + print(path) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) From 850756380add0b01ce1efc6c69c42ccb723f4a9a Mon Sep 17 00:00:00 2001 From: Nathan Huh Date: Thu, 18 Jun 2026 16:09:17 +0900 Subject: [PATCH 2/2] fix: address PR #230 review --- .../scripts/capture_unic_tui.py | 59 +++++++++++++++---- 1 file changed, 47 insertions(+), 12 deletions(-) diff --git a/.agents/skills/unic-ux-review/scripts/capture_unic_tui.py b/.agents/skills/unic-ux-review/scripts/capture_unic_tui.py index e8c3958..46a4e77 100644 --- a/.agents/skills/unic-ux-review/scripts/capture_unic_tui.py +++ b/.agents/skills/unic-ux-review/scripts/capture_unic_tui.py @@ -74,6 +74,15 @@ def run(cmd: list[str], cwd: Path, *, capture: bool = False) -> subprocess.Compl ) +def describe_command_error(error: subprocess.CalledProcessError) -> str: + cmd = error.cmd if isinstance(error.cmd, list) else [str(error.cmd)] + lines = [f"command failed ({error.returncode}): {shlex.join(cmd)}"] + output = (error.stderr or error.stdout or "").strip() + if output: + lines.append(output) + return "\n".join(lines) + + def write_fixture_config(config_root: Path) -> None: unic_dir = config_root / "unic" unic_dir.mkdir(parents=True, exist_ok=True) @@ -90,7 +99,7 @@ def wait_for_capture(session: str, repo_root: Path, expected: str, timeout: floa if expected in last: return last time.sleep(0.15) - return last + raise TimeoutError(f"expected {expected!r} was not found in tmux output after {timeout:.1f}s") def capture_pane(session: str, repo_root: Path) -> str: @@ -101,7 +110,8 @@ def capture_pane(session: str, repo_root: Path) -> str: def render_png(src: Path, dest: Path, width: int, height: int) -> bool: try: from PIL import Image, ImageDraw, ImageFont - except Exception: + except ImportError as error: + print(f"Pillow is not available, skipping PNG render: {error}", file=sys.stderr) return False font_candidates = [ @@ -113,9 +123,14 @@ def render_png(src: Path, dest: Path, width: int, height: int) -> bool: ] font_path = next((p for p in font_candidates if Path(p).exists()), None) if font_path is None: + print("No supported monospace font found, skipping PNG render", file=sys.stderr) return False - font = ImageFont.truetype(font_path, 18) + try: + font = ImageFont.truetype(font_path, 18) + except OSError as error: + print(f"Failed to load font {font_path}: {error}", file=sys.stderr) + return False cell_w = int(round(font.getlength("M"))) bbox = font.getbbox("Ag") line_h = int((bbox[3] - bbox[1]) * 1.45) @@ -159,7 +174,11 @@ def line_color(raw: str, index: int) -> tuple[int, int, int]: draw.rectangle((0, y - 2, img.width, y + line_h - 2), fill=colors["bar"]) draw.text((pad_x, y), line, font=font, fill=line_color(line, index)) - img.save(dest) + try: + img.save(dest) + except OSError as error: + print(f"Failed to save PNG {dest}: {error}", file=sys.stderr) + return False return True @@ -184,7 +203,11 @@ def main() -> int: return 2 if not args.skip_build: - run(["make", "build"], repo_root) + try: + run(["make", "build"], repo_root) + except subprocess.CalledProcessError as error: + print(f"Build failed:\n{describe_command_error(error)}", file=sys.stderr) + return 1 binary = repo_root / "unic" if not binary.exists(): @@ -204,19 +227,31 @@ def main() -> int: created = False outputs: list[Path] = [] try: - run( - ["tmux", "new-session", "-d", "-s", session, "-x", str(args.width), "-y", str(args.height), command], - repo_root, - ) + try: + run( + ["tmux", "new-session", "-d", "-s", session, "-x", str(args.width), "-y", str(args.height), command], + repo_root, + ) + except subprocess.CalledProcessError as error: + print(f"Failed to create tmux session:\n{describe_command_error(error)}", file=sys.stderr) + return 1 created = True - context_text = wait_for_capture(session, repo_root, "Select Context") + try: + context_text = wait_for_capture(session, repo_root, "Select Context") + except (subprocess.CalledProcessError, TimeoutError) as error: + print(f"Failed to capture context picker: {error}", file=sys.stderr) + return 1 context_path = out_dir / "unic-context.txt" context_path.write_text(context_text, encoding="utf-8") outputs.append(context_path) - run(["tmux", "send-keys", "-t", session, "Escape"], repo_root) - service_text = wait_for_capture(session, repo_root, "Select AWS Service") + try: + run(["tmux", "send-keys", "-t", session, "Escape"], repo_root) + service_text = wait_for_capture(session, repo_root, "Select AWS Service") + except (subprocess.CalledProcessError, TimeoutError) as error: + print(f"Failed to capture service picker: {error}", file=sys.stderr) + return 1 service_path = out_dir / "unic-service.txt" service_path.write_text(service_text, encoding="utf-8") outputs.append(service_path)