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..46a4e77 --- /dev/null +++ b/.agents/skills/unic-ux-review/scripts/capture_unic_tui.py @@ -0,0 +1,275 @@ +#!/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 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) + (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) + 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: + 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 ImportError as error: + print(f"Pillow is not available, skipping PNG render: {error}", file=sys.stderr) + 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: + print("No supported monospace font found, skipping PNG render", file=sys.stderr) + return False + + 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) + 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)) + + try: + img.save(dest) + except OSError as error: + print(f"Failed to save PNG {dest}: {error}", file=sys.stderr) + return False + 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: + 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(): + 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: + 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 + + 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) + + 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) + + 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())