diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 31f0a48..1cc9563 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ { "name": "gateflow", "description": "AI-powered hardware development platform \u2014 design, verify, synthesize, release, and deploy working RTL with natural language. 20 agents, 27 skills, 8 IP blocks.", - "version": "2.5.2", + "version": "2.5.3", "author": { "name": "codejunkie99", "github": "https://github.com/codejunkie99" diff --git a/README.md b/README.md index 4a97537..83fece8 100644 --- a/README.md +++ b/README.md @@ -154,6 +154,23 @@ Skills activate automatically based on context: | `/gf-tui` | Open the local GateFlow terminal console | | `/gf-release` | Validate plugin release readiness | +### Local CLI + +GateFlow also ships with a command-first local CLI for plugin maintenance and +agent creation: + +```bash +python3 tools/gateflow_cli.py status +python3 tools/gateflow_cli.py agents list +python3 tools/gateflow_cli.py agents create "CDC Reviewer" \ + --role "clock-domain crossing reviewer" \ + --description "Reviews synchronizers and CDC constraints" +python3 tools/gateflow_cli.py shell +python3 tools/gateflow_cli.py tui +``` + +Inside the TUI, press `a` to create a new agent without leaving the dashboard. + ### Example Session ``` @@ -590,6 +607,7 @@ For detailed release notes, see [`releases.md`](releases.md). | Version | Date | What Changed | |---------|------|-------------| +| **2.5.3** | 2026-05-21 | Command-first local CLI, interactive agent creation, and richer terminal colors | | **2.5.2** | 2026-05-21 | Responsive TUI layout for narrow terminal windows | | **2.5.1** | 2026-05-21 | TUI terminal compatibility fixes for cursor and colorless PTYs | | **2.5.0** | 2026-05-21 | OpenClaw-style CLI/TUI, release readiness workflow, deterministic validators, synced marketplace/docs/index/mirrors | diff --git a/docs/gateflow.index b/docs/gateflow.index index f8bda79..ff9bb70 100644 --- a/docs/gateflow.index +++ b/docs/gateflow.index @@ -6,6 +6,9 @@ primary|CLAUDE.md|SV patterns, always_ff/comb, FSM, CDC, lint fixes, Spear/Tumbu primary|README.md|Installation, usage, features, component inventory primary|plugins/gateflow/README.md|Plugin-local overview and quick start primary|releases.md|Release notes and version history +tools|tools/gateflow_cli.py|Command-first local CLI with status, shell, TUI, and agent creation +tools|tools/gateflow_tui.py|Keyboard dashboard with semantic color and interactive agent creation +tools|tools/validate_gateflow.py|Release metadata and package wiring validator commands|commands/gf-audit.md|Audit plugin quality and optionally auto-fix issues commands|commands/gf-boards.md|List supported FPGA boards and query pinouts diff --git a/plugins/gateflow/.claude-plugin/plugin.json b/plugins/gateflow/.claude-plugin/plugin.json index e059ea5..67f30a5 100644 --- a/plugins/gateflow/.claude-plugin/plugin.json +++ b/plugins/gateflow/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "gateflow", - "version": "2.5.2", + "version": "2.5.3", "description": "AI-powered hardware development platform \u2014 design, verify, synthesize, release, and deploy working RTL with natural language. 20 agents, 27 skills, 8 IP blocks.", "author": { "name": "codejunkie99", diff --git a/plugins/gateflow/README.md b/plugins/gateflow/README.md index 52fda62..fca2c43 100644 --- a/plugins/gateflow/README.md +++ b/plugins/gateflow/README.md @@ -107,6 +107,21 @@ claude plugin add codejunkie99/Gateflow-Plugin | `/gf-tui` | Local terminal console | | `/gf-release` | Release readiness validation | +### Local CLI + +From the repository root: + +```bash +python3 tools/gateflow_cli.py status +python3 tools/gateflow_cli.py agents list +python3 tools/gateflow_cli.py agents create "CDC Reviewer" \ + --role "clock-domain crossing reviewer" \ + --description "Reviews synchronizers and CDC constraints" +python3 tools/gateflow_cli.py shell +``` + +Inside the keyboard dashboard, press `a` to create a new agent. + ### 8 Verified IP Blocks Every block ships with RTL + testbench + formal properties + docs. @@ -205,6 +220,9 @@ claude plugin add codejunkie99/Gateflow-Plugin # Open terminal console /gf-tui + +# Use the local CLI +python3 tools/gateflow_cli.py shell ``` --- diff --git a/plugins/gateflow/commands/gf-tui.md b/plugins/gateflow/commands/gf-tui.md index 42af760..9d724f0 100644 --- a/plugins/gateflow/commands/gf-tui.md +++ b/plugins/gateflow/commands/gf-tui.md @@ -9,7 +9,7 @@ allowed-tools: # GateFlow TUI Command -Open the local GateFlow terminal console. +Open the local GateFlow terminal console and local CLI. ## Usage @@ -21,12 +21,25 @@ Open the local GateFlow terminal console. ## Execution -Run from the repository root: +Run the command-first CLI from the repository root: + +```bash +python3 tools/gateflow_cli.py status +python3 tools/gateflow_cli.py agents list +python3 tools/gateflow_cli.py agents create "CDC Reviewer" \ + --role "clock-domain crossing reviewer" \ + --description "Reviews synchronizers and CDC constraints" +python3 tools/gateflow_cli.py shell +``` + +Open the keyboard dashboard: ```bash python3 tools/gateflow_tui.py ``` +Inside the dashboard, press `a` to create a new agent. + Use snapshot mode when running in a non-interactive terminal: ```bash @@ -39,5 +52,6 @@ python3 tools/gateflow_tui.py --snapshot --plain - component inventory - local hardware tool health - map/release readiness +- interactive agent creation - quick actions for `/gf-doctor`, `/gf-map`, `/gf-viz`, `/gf-lint`, `/gf-sim`, `/gf-formal`, and `/gf-release` diff --git a/plugins/gateflow/skills/gf-tui/SKILL.md b/plugins/gateflow/skills/gf-tui/SKILL.md index c2a5ac5..637fb7a 100644 --- a/plugins/gateflow/skills/gf-tui/SKILL.md +++ b/plugins/gateflow/skills/gf-tui/SKILL.md @@ -24,6 +24,9 @@ Launch a local terminal console for GateFlow. | Mode | Command | Use When | |---|---|---| +| CLI | `python3 tools/gateflow_cli.py status` | You want a normal command surface | +| Shell | `python3 tools/gateflow_cli.py shell` | You want a local `gateflow>` prompt | +| Agent create | `python3 tools/gateflow_cli.py agents create "Name"` | You need a new custom agent | | Interactive | `python3 tools/gateflow_tui.py` | You are in a real TTY and want keyboard navigation | | Snapshot | `python3 tools/gateflow_tui.py --snapshot --plain` | Logs, CI, or non-interactive terminals | | JSON | `python3 tools/gateflow_tui.py --json` | Scripts need machine-readable state | @@ -32,6 +35,8 @@ Launch a local terminal console for GateFlow. - Local workspace mode by default; no gateway is required. - TTY-aware styling with plain and JSON fallbacks. +- Command-first local CLI for status and agent management. +- Press `a` in the dashboard to create a new agent interactively. - Health/status surfaces are visible before action. - Commands are shown as operator shortcuts rather than hidden docs. - Release and config repair loops stay inside the terminal workflow. @@ -44,6 +49,7 @@ Launch a local terminal console for GateFlow. Yosys, and SymbiYosys availability. 4. **Actions** — launch points for doctor, map, viz, lint, sim, formal, and release workflows. +5. **Agent creation** — `a` opens prompts for name, role, and description. ## Guardrails @@ -59,6 +65,8 @@ Run: ```bash python3 -m unittest tests/test_gateflow_tui.py +python3 -m unittest tests/test_gateflow_cli.py +python3 tools/gateflow_cli.py --plain status python3 tools/gateflow_tui.py --snapshot --plain python3 tools/gateflow_tui.py --json ``` diff --git a/releases.md b/releases.md index aa1bb40..d5eeeaa 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,22 @@ # Releases +## 2.5.3 (2026-05-21) — Command CLI + Agent Creation + +Patch release for making the local terminal surface behave like a real CLI. + +### New Features +- Added `tools/gateflow_cli.py` with `status`, `tui`, `agents list`, + `agents create`, and `shell` subcommands. +- Added local agent creation that writes Claude-compatible agent markdown under + `plugins/gateflow/agents/`. +- Added `a` inside the TUI to create a new agent without leaving the dashboard. + +### Polish +- Reworked terminal colors around semantic state: copper identity, cyan + headings/navigation, green ready states, amber warnings, and muted separators. +- Added regression coverage for agent creation, CLI JSON status, shell help, + rich curses color-pair setup, and stacked health-row styles. + ## 2.5.2 (2026-05-21) — Responsive TUI Layout Patch release for narrow terminal windows. diff --git a/tests/test_gateflow_cli.py b/tests/test_gateflow_cli.py new file mode 100644 index 0000000..ee1ed0f --- /dev/null +++ b/tests/test_gateflow_cli.py @@ -0,0 +1,125 @@ +import importlib.util +import json +import subprocess +import sys +import tempfile +import unittest +from io import StringIO +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +CLI = ROOT / "tools" / "gateflow_cli.py" + + +def load_cli(): + spec = importlib.util.spec_from_file_location("gateflow_cli", CLI) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class GateFlowCliTests(unittest.TestCase): + def test_create_agent_writes_claude_agent_file(self): + cli = load_cli() + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + result = cli.create_agent( + root=root, + name="Timing Closer", + role="timing closure specialist", + description="Closes timing on FPGA builds", + color="cyan", + tools=["Read", "Edit", "Bash"], + force=False, + ) + + self.assertEqual(root / "plugins/gateflow/agents/timing-closer.md", result.path) + content = result.path.read_text(encoding="utf-8") + self.assertIn("name: timing-closer", content) + self.assertIn("color: cyan", content) + self.assertIn(" - Bash", content) + self.assertIn("timing closure specialist", content) + self.assertIn("Closes timing on FPGA builds", content) + + def test_cli_agents_create_outputs_created_path(self): + with tempfile.TemporaryDirectory() as tmp: + completed = subprocess.run( + [ + sys.executable, + str(CLI), + "--root", + tmp, + "--plain", + "agents", + "create", + "CDC Reviewer", + "--role", + "clock-domain crossing reviewer", + "--description", + "Reviews synchronizers and CDC constraints", + "--tool", + "Read", + "--tool", + "Grep", + ], + text=True, + capture_output=True, + check=False, + ) + + self.assertEqual("", completed.stderr) + self.assertEqual(0, completed.returncode) + self.assertIn("created", completed.stdout) + self.assertIn("cdc-reviewer.md", completed.stdout) + self.assertTrue((Path(tmp) / "plugins/gateflow/agents/cdc-reviewer.md").exists()) + + def test_cli_status_json_returns_plugin_payload(self): + completed = subprocess.run( + [sys.executable, str(CLI), "--root", str(ROOT), "status", "--json"], + text=True, + capture_output=True, + check=False, + ) + + self.assertEqual("", completed.stderr) + self.assertEqual(0, completed.returncode) + payload = json.loads(completed.stdout) + self.assertEqual("gateflow", payload["plugin"]["name"]) + self.assertIn("actions", payload) + + def test_shell_help_mentions_agent_creation(self): + cli = load_cli() + + help_text = cli.shell_help() + + self.assertIn("create-agent", help_text) + self.assertIn("agents create", help_text) + + def test_agents_list_clips_long_descriptions(self): + cli = load_cli() + + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + cli.create_agent( + root=root, + name="Long Description Agent", + role="agent list display test", + description="Reviews " + "very " * 30 + "long agent descriptions", + color="green", + tools=["Read"], + force=False, + ) + output = StringIO() + + result = cli._print_agents(root, as_json=False, plain=True, output=output) + + lines = output.getvalue().splitlines() + self.assertEqual(0, result) + self.assertLessEqual(max(len(line) for line in lines), 100) + self.assertIn("...", output.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_gateflow_tui.py b/tests/test_gateflow_tui.py index e1d83fc..766c923 100644 --- a/tests/test_gateflow_tui.py +++ b/tests/test_gateflow_tui.py @@ -36,7 +36,7 @@ def test_json_mode_returns_machine_readable_inventory(self): payload = tui.build_payload(ROOT) self.assertEqual("gateflow", payload["plugin"]["name"]) - self.assertEqual("2.5.2", payload["plugin"]["version"]) + self.assertEqual("2.5.3", payload["plugin"]["version"]) self.assertEqual(20, payload["inventory"]["agents"]) self.assertEqual(27, payload["inventory"]["skills"]) self.assertEqual(21, payload["inventory"]["commands"]) @@ -95,6 +95,52 @@ def color_pair(_pair): self.assertEqual(FakeCurses.A_DIM, styles["muted"]) self.assertEqual(0, styles["footer"]) + def test_terminal_styles_use_semantic_color_pairs_when_supported(self): + tui = load_tui() + + class FakeCurses: + error = RuntimeError + COLORS = 256 + COLOR_BLACK = 0 + COLOR_RED = 1 + COLOR_GREEN = 2 + COLOR_YELLOW = 3 + COLOR_BLUE = 4 + COLOR_MAGENTA = 5 + COLOR_CYAN = 6 + A_BOLD = 0x100 + A_DIM = 0x200 + A_REVERSE = 0x400 + calls = [] + + @staticmethod + def start_color(): + return None + + @staticmethod + def use_default_colors(): + return None + + @staticmethod + def has_colors(): + return True + + @staticmethod + def init_pair(pair, foreground, background): + FakeCurses.calls.append((pair, foreground, background)) + + @staticmethod + def color_pair(pair): + return pair << 12 + + styles = tui._terminal_styles(FakeCurses) + + self.assertIn((1, 208, -1), FakeCurses.calls) + self.assertIn((7, 16, 214), FakeCurses.calls) + self.assertEqual((1 << 12) | FakeCurses.A_BOLD, styles["accent"]) + self.assertEqual((7 << 12) | FakeCurses.A_BOLD, styles["selected"]) + self.assertEqual((8 << 12) | FakeCurses.A_BOLD, styles["heading"]) + def test_narrow_terminals_use_stacked_layout(self): tui = load_tui() @@ -141,6 +187,19 @@ def refresh(self): self.assertNotIn("Workspace", action_row) self.assertGreater(workspace_row, 11) + def test_stacked_health_rows_have_semantic_styles(self): + tui = load_tui() + payload = tui.build_payload(ROOT) + + rows = tui._dashboard_rows(payload, 80, 0, "") + + def style_for(prefix): + return next(style for text, style in rows if text.strip().startswith(prefix)) + + self.assertEqual("status_ok", style_for("doctor")) + self.assertEqual("status_ok", style_for("release")) + self.assertEqual("status_warn", style_for("map")) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_validate_gateflow.py b/tests/test_validate_gateflow.py index 05956a2..6c3a784 100644 --- a/tests/test_validate_gateflow.py +++ b/tests/test_validate_gateflow.py @@ -35,13 +35,13 @@ def test_inventory_matches_release_target(self): def test_repository_passes_release_checks(self): validator = load_validator() - result = validator.run_checks(ROOT, expected_version="2.5.2") + result = validator.run_checks(ROOT, expected_version="2.5.3") self.assertEqual([], result.errors) def test_cli_reports_success(self): completed = subprocess.run( - [sys.executable, str(VALIDATOR), "--version", "2.5.2"], + [sys.executable, str(VALIDATOR), "--version", "2.5.3"], cwd=ROOT, text=True, capture_output=True, diff --git a/tools/gateflow_cli.py b/tools/gateflow_cli.py new file mode 100644 index 0000000..aa7e1eb --- /dev/null +++ b/tools/gateflow_cli.py @@ -0,0 +1,357 @@ +#!/usr/bin/env python3 +"""Command-first local CLI for the GateFlow plugin.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import os +import re +import shlex +import sys +import textwrap +from pathlib import Path +from typing import NamedTuple, TextIO + + +TOOLS_DIR = Path(__file__).resolve().parent + +ACCENT = "\033[38;2;255;132;45m" +INFO = "\033[38;2;68;201;224m" +SUCCESS = "\033[38;2;59;201;128m" +WARN = "\033[38;2;255;191;71m" +ERROR = "\033[38;2;234;80;64m" +MUTED = "\033[38;2;147;143;135m" +RESET = "\033[0m" + +DEFAULT_TOOLS = ["Read", "Glob", "Grep", "Edit", "Bash"] +VALID_COLORS = { + "blue", + "cyan", + "green", + "orange", + "pink", + "purple", + "red", + "yellow", +} + + +class AgentCreateResult(NamedTuple): + path: Path + slug: str + created: bool + + +def _color(text: str, color: str, plain: bool) -> str: + return text if plain or os.environ.get("NO_COLOR") else f"{color}{text}{RESET}" + + +def _fit_cell(text: str, width: int) -> str: + text = " ".join(text.split()) + if len(text) <= width: + return text + if width <= 3: + return "." * width + return text[: width - 3].rstrip() + "..." + + +def _load_tui(): + path = TOOLS_DIR / "gateflow_tui.py" + spec = importlib.util.spec_from_file_location("gateflow_tui", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def slugify_agent_name(name: str) -> str: + slug = re.sub(r"[^a-z0-9]+", "-", name.strip().lower()).strip("-") + if not slug: + raise ValueError("agent name must contain at least one letter or number") + return slug + + +def _wrap_yaml(value: str, indent: str = " ") -> str: + lines = textwrap.wrap(value.strip(), width=76) or ["Custom GateFlow agent."] + return "\n".join(f"{indent}{line}" for line in lines) + + +def render_agent_markdown( + *, + name: str, + role: str, + description: str, + color: str, + tools: list[str], +) -> str: + slug = slugify_agent_name(name) + title = " ".join(part.capitalize() for part in slug.split("-")) + tool_lines = "\n".join(f" - {tool}" for tool in tools) + description_block = _wrap_yaml(description) + + return f"""--- +name: {slug} +description: > +{description_block} +color: {color} +tools: +{tool_lines} +--- + +# {title} + +You are a GateFlow agent focused on {role}. + +## When To Use + +Use this agent when the task needs: {description} + +## Workflow + +1. Read the relevant RTL, testbench, constraints, or plugin files. +2. State the concrete objective before changing files. +3. Make the smallest useful change that advances the hardware workflow. +4. Verify with the relevant GateFlow command, simulator, lint tool, or release check. +5. Report the files changed and the next command the user should run. + +## Return Format + +```text +---GATEFLOW-RETURN--- +STATUS: complete|needs_clarification|blocked +SUMMARY: [what changed] +FILES_CREATED: [new files] +FILES_MODIFIED: [changed files] +NEXT_TARGET: [next GateFlow command or agent] +---END-GATEFLOW-RETURN--- +``` +""" + + +def create_agent( + *, + root: Path, + name: str, + role: str, + description: str, + color: str = "cyan", + tools: list[str] | None = None, + force: bool = False, +) -> AgentCreateResult: + slug = slugify_agent_name(name) + if color not in VALID_COLORS: + raise ValueError(f"unsupported color '{color}'. Choose: {', '.join(sorted(VALID_COLORS))}") + selected_tools = tools or DEFAULT_TOOLS + agent_dir = root / "plugins" / "gateflow" / "agents" + path = agent_dir / f"{slug}.md" + if path.exists() and not force: + raise FileExistsError(f"agent already exists: {path}") + + agent_dir.mkdir(parents=True, exist_ok=True) + path.write_text( + render_agent_markdown( + name=name, + role=role, + description=description, + color=color, + tools=selected_tools, + ), + encoding="utf-8", + ) + return AgentCreateResult(path=path, slug=slug, created=True) + + +def list_agents(root: Path) -> list[dict[str, str]]: + agents = [] + for path in sorted((root / "plugins" / "gateflow" / "agents").glob("*.md")): + content = path.read_text(encoding="utf-8", errors="replace") + name_match = re.search(r"^name:\s*(.+)$", content, re.MULTILINE) + color_match = re.search(r"^color:\s*(.+)$", content, re.MULTILINE) + desc_match = re.search(r"^description:\s*>\s*\n((?: .+\n?)*)", content, re.MULTILINE) + description = "" + if desc_match: + description = " ".join(line.strip() for line in desc_match.group(1).splitlines()).strip() + agents.append( + { + "name": name_match.group(1).strip() if name_match else path.stem, + "color": color_match.group(1).strip() if color_match else "default", + "description": description, + "path": str(path), + } + ) + return agents + + +def shell_help() -> str: + return """GateFlow local CLI + +Commands: + status Show plugin inventory and local health + agents List GateFlow agents + agents create NAME Create a new agent from flags + create-agent NAME Interactive shortcut for creating a new agent + tui Open the keyboard dashboard + help Show this help + quit Exit + +Examples: + agents create "CDC Reviewer" --role "CDC reviewer" --description "Reviews synchronizers" + create-agent "Timing Closer" +""" + + +def _print_status(root: Path, *, as_json: bool, plain: bool, output: TextIO) -> int: + tui = _load_tui() + payload = tui.build_payload(root) + if as_json: + print(json.dumps(payload, indent=2, sort_keys=True), file=output) + return 0 + + inv = payload["inventory"] + health = payload["health"] + print(_color(f"GateFlow {payload['plugin']['version']}", ACCENT, plain), file=output) + print(_color(payload["mode"], MUTED, plain), file=output) + print(f"workspace {payload['workspace']}", file=output) + print( + f"inventory {inv['agents']} agents {inv['skills']} skills " + f"{inv['commands']} commands {inv['ip_blocks']} IP blocks", + file=output, + ) + for key in ("doctor", "release", "verilator", "yosys", "sby"): + value = health[key] + color = SUCCESS if value in {"ready", "installed"} else WARN + print(f"{key:<10} {_color(str(value), color, plain)}", file=output) + print(f"{'map':<10} {_color(health['map']['status'], WARN, plain)}", file=output) + return 0 + + +def _print_agents(root: Path, *, as_json: bool, plain: bool, output: TextIO) -> int: + agents = list_agents(root) + if as_json: + print(json.dumps(agents, indent=2, sort_keys=True), file=output) + return 0 + if not agents: + print(_color("no agents found", WARN, plain), file=output) + return 0 + print(_color("GateFlow agents", ACCENT, plain), file=output) + for agent in agents: + description = _fit_cell(agent["description"] or "no description", 66) + print(f"{agent['name']:<22} {agent['color']:<7} {description}", file=output) + return 0 + + +def _agent_create_from_args(args, output: TextIO) -> int: + try: + result = create_agent( + root=args.root, + name=args.name, + role=args.role, + description=args.description, + color=args.color, + tools=args.tool, + force=args.force, + ) + except (FileExistsError, ValueError) as error: + print(_color(f"error: {error}", ERROR, args.plain), file=sys.stderr) + return 2 + print(_color("created", SUCCESS, args.plain), result.path, file=output) + return 0 + + +def run_shell(root: Path, *, plain: bool, input_stream: TextIO = sys.stdin, output: TextIO = sys.stdout) -> int: + print(_color("GateFlow CLI", ACCENT, plain), file=output) + print("type 'help' for commands, 'quit' to exit", file=output) + while True: + print(_color("gateflow> ", INFO, plain), end="", file=output, flush=True) + line = input_stream.readline() + if not line: + return 0 + command = line.strip() + if not command: + continue + if command in {"quit", "exit", "q"}: + return 0 + if command == "help": + print(shell_help(), file=output) + continue + if command == "status": + _print_status(root, as_json=False, plain=plain, output=output) + continue + if command in {"agents", "agents list"}: + _print_agents(root, as_json=False, plain=plain, output=output) + continue + if command == "tui": + return _load_tui().run_interactive(root) + if command.startswith("create-agent"): + parts = shlex.split(command) + name = parts[1] if len(parts) > 1 else input("agent name: ").strip() + role = input("role: ").strip() or "custom GateFlow specialist" + description = input("description: ").strip() or role + result = create_agent( + root=root, + name=name, + role=role, + description=description, + color="cyan", + tools=DEFAULT_TOOLS, + force=False, + ) + print(_color("created", SUCCESS, plain), result.path, file=output) + continue + print(_color(f"unknown command: {command}", ERROR, plain), file=output) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="gateflow", description=__doc__) + parser.add_argument("--root", type=Path, default=Path.cwd(), help="Repository root") + parser.add_argument("--plain", action="store_true", help="Disable ANSI styling") + subcommands = parser.add_subparsers(dest="command") + + status = subcommands.add_parser("status", help="Show local GateFlow status") + status.add_argument("--json", action="store_true", help="Print machine-readable status") + + agents = subcommands.add_parser("agents", help="Manage GateFlow agents") + agent_commands = agents.add_subparsers(dest="agent_command") + agent_list = agent_commands.add_parser("list", help="List agents") + agent_list.add_argument("--json", action="store_true", help="Print machine-readable agents") + create = agent_commands.add_parser("create", help="Create a new agent") + create.add_argument("name", help="Agent display name, e.g. 'CDC Reviewer'") + create.add_argument("--role", default="custom GateFlow specialist", help="Agent role line") + create.add_argument("--description", default="Custom GateFlow workflow agent", help="Trigger description") + create.add_argument("--color", default="cyan", choices=sorted(VALID_COLORS), help="Claude agent color") + create.add_argument("--tool", action="append", default=None, help="Allowed tool, repeatable") + create.add_argument("--force", action="store_true", help="Overwrite an existing agent") + + subcommands.add_parser("tui", help="Open the keyboard dashboard") + subcommands.add_parser("shell", help="Open the interactive command shell") + return parser + + +def main(argv: list[str] | None = None, output: TextIO = sys.stdout) -> int: + parser = build_parser() + args = parser.parse_args(argv) + args.root = args.root.resolve() + + if args.command is None: + if sys.stdin.isatty(): + return run_shell(args.root, plain=args.plain, output=output) + return _print_status(args.root, as_json=False, plain=args.plain, output=output) + if args.command == "status": + return _print_status(args.root, as_json=args.json, plain=args.plain, output=output) + if args.command == "agents": + if args.agent_command in {None, "list"}: + return _print_agents(args.root, as_json=getattr(args, "json", False), plain=args.plain, output=output) + if args.agent_command == "create": + return _agent_create_from_args(args, output) + if args.command == "tui": + return _load_tui().run_interactive(args.root) + if args.command == "shell": + return run_shell(args.root, plain=args.plain, output=output) + parser.print_help(output) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/gateflow_tui.py b/tools/gateflow_tui.py index 35260de..38a308e 100755 --- a/tools/gateflow_tui.py +++ b/tools/gateflow_tui.py @@ -17,6 +17,7 @@ SUCCESS = "\033[38;2;47;191;113m" WARN = "\033[38;2;255;176;32m" ERROR = "\033[38;2;226;61;45m" +INFO = "\033[38;2;68;201;224m" MUTED = "\033[38;2;139;127;119m" RESET = "\033[0m" @@ -29,6 +30,14 @@ def _load_validator(): return module +def _load_cli(): + path = Path(__file__).with_name("gateflow_cli.py") + spec = importlib.util.spec_from_file_location("gateflow_cli", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + def _read_json(path: Path) -> dict: try: return json.loads(path.read_text(encoding="utf-8")) @@ -113,31 +122,77 @@ def _hide_cursor(curses_module=curses) -> bool: def _terminal_styles(curses_module=curses) -> dict[str, int]: + bold = getattr(curses_module, "A_BOLD", 0) + dim = getattr(curses_module, "A_DIM", 0) + reverse = getattr(curses_module, "A_REVERSE", 0) styles = { - "accent": getattr(curses_module, "A_BOLD", 0), + "accent": bold, + "error": bold, + "footer": 0, + "heading": bold, + "info": 0, + "muted": dim, "ok": 0, + "selected": reverse, "warn": 0, - "muted": getattr(curses_module, "A_DIM", 0), - "footer": 0, } + try: - curses_module.init_pair(1, curses_module.COLOR_RED, -1) - curses_module.init_pair(2, curses_module.COLOR_GREEN, -1) - curses_module.init_pair(3, curses_module.COLOR_YELLOW, -1) - curses_module.init_pair(4, curses_module.COLOR_CYAN, -1) - styles.update( - { - "accent": curses_module.color_pair(1) | getattr(curses_module, "A_BOLD", 0), - "ok": curses_module.color_pair(2), - "warn": curses_module.color_pair(3), - "footer": curses_module.color_pair(4), - } - ) + if hasattr(curses_module, "start_color"): + curses_module.start_color() + if hasattr(curses_module, "use_default_colors"): + curses_module.use_default_colors() + if hasattr(curses_module, "has_colors") and not curses_module.has_colors(): + return styles except (curses_module.error, ValueError): - pass + return styles + + extended = getattr(curses_module, "COLORS", 0) >= 256 + + def basic(name: str, fallback: int = 0) -> int: + return getattr(curses_module, name, fallback) + + def init_style( + key: str, + pair: int, + fg_256: int, + fg_basic: int, + attr: int = 0, + bg_256: int = -1, + bg_basic: int = -1, + ) -> None: + foreground = fg_256 if extended else fg_basic + background = bg_256 if extended else bg_basic + try: + curses_module.init_pair(pair, foreground, background) + styles[key] = curses_module.color_pair(pair) | attr + except (curses_module.error, ValueError): + return + + init_style("accent", 1, 208, basic("COLOR_YELLOW"), bold) + init_style("ok", 2, 82, basic("COLOR_GREEN"), bold) + init_style("warn", 3, 214, basic("COLOR_YELLOW"), bold) + init_style("error", 4, 196, basic("COLOR_RED"), bold) + init_style("info", 5, 45, basic("COLOR_CYAN")) + init_style("muted", 6, 244, basic("COLOR_BLUE"), dim) + init_style("selected", 7, 16, basic("COLOR_BLACK"), bold, 214, basic("COLOR_YELLOW")) + init_style("heading", 8, 45, basic("COLOR_CYAN"), bold) + init_style("footer", 9, 45, basic("COLOR_CYAN")) return styles +def _status_style(value: str | dict) -> str: + if isinstance(value, dict): + value = value.get("status", "unknown") + if value in {"ready", "installed"}: + return "status_ok" + if value in {"missing", "unknown"}: + return "status_warn" + if "issues" in value: + return "status_error" + return "normal" + + def _layout_mode(width: int) -> str: return "stacked" if width < 100 else "columns" @@ -189,12 +244,19 @@ def row(text: str = "", style: str = "normal") -> None: row(f" {inv['ip_blocks']} IP blocks {inv['boards']} boards") row() row("Health", "heading") - row(f" doctor {health['doctor']:<10} release {health['release']}") - row(f" map {health['map']['status']:<10} verilator {health['verilator']}") - row(f" yosys {health['yosys']:<10} sby {health['sby']}") + health_rows = [ + ("doctor", health["doctor"]), + ("release", health["release"]), + ("map", health["map"]["status"]), + ("verilator", health["verilator"]), + ("yosys", health["yosys"]), + ("sby", health["sby"]), + ] + for name, value in health_rows: + row(f" {name:<10} {value}", _status_style(value)) row() row("─" * max_width, "muted") - row(message or "↑/↓ select Enter show command r refresh q quit", "footer") + row(message or "Up/Down select Enter command a agent r refresh q quit", "footer") return rows @@ -208,18 +270,18 @@ def render_snapshot(root: Path, plain: bool = False) -> str: _color("GateFlow Terminal", ACCENT, plain), f"{payload['mode']}", "", - "Workspace", + _color("Workspace", INFO, plain), f" path {payload['workspace']}", f" plugin {payload['plugin']['name']} {payload['plugin']['version']}", "", - "Inventory", + _color("Inventory", INFO, plain), f" agents {inv['agents']}", f" skills {inv['skills']}", f" commands {inv['commands']}", f" IP blocks {inv['ip_blocks']}", f" boards {inv['boards']}", "", - "Health", + _color("Health", INFO, plain), f" doctor {_status(health['doctor'], plain)}", f" release {_status(health['release'], plain)}", f" map {_status(health['map'], plain)} ({health['map']['detail']})", @@ -227,12 +289,12 @@ def render_snapshot(root: Path, plain: bool = False) -> str: f" yosys {_status(health['yosys'], plain)}", f" sby {_status(health['sby'], plain)}", "", - "Actions", + _color("Actions", INFO, plain), ] width = max(len(action["command"]) for action in actions) for index, action in enumerate(actions, start=1): lines.append(f" {index}. {action['command']:<{width}} {action['description']}") - lines.extend(["", "Run without --snapshot in a TTY for interactive navigation. Press q to exit."]) + lines.extend(["", "Run without --snapshot in a TTY. Press a to create an agent, q to exit."]) return "\n".join(lines) + "\n" @@ -254,6 +316,7 @@ def add(y: int, x: int, text: str, attr=0) -> None: ok = styles["ok"] warn = styles["warn"] muted = styles["muted"] + heading = styles["heading"] add(0, 0, " GateFlow Terminal ", accent) add(0, 20, payload["mode"], muted) @@ -263,21 +326,25 @@ def add(y: int, x: int, text: str, attr=0) -> None: style_attrs = { "accent": accent, "footer": styles["footer"], - "heading": curses.A_BOLD, + "heading": heading, + "info": styles["info"], "muted": muted, "normal": 0, - "selected": curses.A_REVERSE, + "selected": styles["selected"], + "status_error": styles["error"], + "status_ok": ok, + "status_warn": warn, } for y, (text, style) in enumerate(_dashboard_rows(payload, width, selected, message)): add(y, 0, text, style_attrs.get(style, 0)) stdscr.refresh() return - add(3, 2, "Actions", curses.A_BOLD) + add(3, 2, "Actions", heading) right_x = 52 if mode == "columns" else 2 action_desc_width = (right_x - 18) if mode == "columns" else (width - 18) for idx, action in enumerate(actions): - attr = curses.A_REVERSE if idx == selected else 0 + attr = styles["selected"] if idx == selected else 0 y = 5 + idx add(y, 2, f"{idx + 1}. {action['command']}", attr) add(y, 16, _fit_text(action["description"], action_desc_width), attr) @@ -291,15 +358,15 @@ def add(y: int, x: int, text: str, attr=0) -> None: inventory_y = workspace_y + 5 health_y = inventory_y + 5 - add(workspace_y, right_x, "Workspace", curses.A_BOLD) + add(workspace_y, right_x, "Workspace", heading) add(workspace_y + 2, right_x, payload["workspace"]) add(workspace_y + 3, right_x, f"{payload['plugin']['name']} {payload['plugin']['version']}", accent) - add(inventory_y, right_x, "Inventory", curses.A_BOLD) + add(inventory_y, right_x, "Inventory", heading) add(inventory_y + 2, right_x, f"{inv['agents']} agents {inv['skills']} skills {inv['commands']} commands") add(inventory_y + 3, right_x, f"{inv['ip_blocks']} IP blocks {inv['boards']} boards") - add(health_y, right_x, "Health", curses.A_BOLD) + add(health_y, right_x, "Health", heading) rows = [ ("doctor", health["doctor"]), ("release", health["release"]), @@ -309,15 +376,53 @@ def add(y: int, x: int, text: str, attr=0) -> None: ("sby", health["sby"]), ] for offset, (name, value) in enumerate(rows): - attr = ok if value in {"ready", "installed"} else warn + attr = styles.get(_status_style(value).replace("status_", ""), 0) add(health_y + 2 + offset, right_x, f"{name:<10} {value}", attr) - footer = message or "↑/↓ select Enter show command r refresh q quit" + footer = message or "Up/Down select Enter show command a agent r refresh q quit" add(height - 2, 0, "─" * (width - 1), muted) add(height - 1, 1, footer, styles["footer"]) stdscr.refresh() +def _prompt(stdscr, prompt: str) -> str: + height, width = stdscr.getmaxyx() + y = height - 1 + max_width = max(1, width - len(prompt) - 3) + stdscr.move(y, 0) + stdscr.clrtoeol() + stdscr.addnstr(y, 1, prompt, width - 2) + try: + curses.echo() + curses.curs_set(1) + value = stdscr.getstr(y, min(width - 2, len(prompt) + 1), max_width) + finally: + curses.noecho() + _hide_cursor() + return value.decode("utf-8", errors="replace").strip() + + +def _create_agent_from_tui(stdscr, root: Path) -> str: + name = _prompt(stdscr, "agent name: ") + if not name: + return "Agent create cancelled" + role = _prompt(stdscr, "role: ") or "custom GateFlow specialist" + description = _prompt(stdscr, "description: ") or role + try: + result = _load_cli().create_agent( + root=root, + name=name, + role=role, + description=description, + color="cyan", + tools=None, + force=False, + ) + except (FileExistsError, ValueError) as error: + return f"Agent create failed: {error}" + return f"Created agent: {result.path.name}" + + def run_interactive(root: Path) -> int: payload = build_payload(root) @@ -338,6 +443,9 @@ def wrapped(stdscr) -> None: elif key in {ord("\n"), curses.KEY_ENTER, 10, 13}: action = payload["actions"][selected] message = f"Run in Claude Code: {action['command']} ({action['description']})" + elif key == ord("a"): + message = _create_agent_from_tui(stdscr, root) + payload.update(build_payload(root)) elif key == ord("r"): payload.update(build_payload(root)) message = "Refreshed" diff --git a/tools/validate_gateflow.py b/tools/validate_gateflow.py index 322f239..e676858 100755 --- a/tools/validate_gateflow.py +++ b/tools/validate_gateflow.py @@ -132,7 +132,7 @@ def _check_symlink(path: Path, expected_target: str, errors: list[str]) -> None: errors.append(f"{path} points to {target}, expected {expected_target}") -def run_checks(root: Path, expected_version: str = "2.5.2") -> ValidationResult: +def run_checks(root: Path, expected_version: str = "2.5.3") -> ValidationResult: root = root.resolve() inventory = discover_inventory(root) errors: list[str] = [] @@ -148,7 +148,7 @@ def run_checks(root: Path, expected_version: str = "2.5.2") -> ValidationResult: def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--root", type=Path, default=Path.cwd(), help="Repository root") - parser.add_argument("--version", default="2.5.2", help="Expected release version") + parser.add_argument("--version", default="2.5.3", help="Expected release version") args = parser.parse_args(argv) result = run_checks(args.root, expected_version=args.version)