diff --git a/docs/E6-z3ed-cli-design.md b/docs/E6-z3ed-cli-design.md index 4503ebb1..077f3593 100644 --- a/docs/E6-z3ed-cli-design.md +++ b/docs/E6-z3ed-cli-design.md @@ -336,3 +336,132 @@ src/cli/ 4. **JSON Integration**: Complete HTTP/JSON library integration for Gemini AI service 5. **Performance**: Address copy-construction warnings by using const references 6. **Testing**: Expand unit test coverage for command handlers + +## 11. Agent-Ready API Surface Area + +To unlock deeper agentic workflows, the CLI and application layers must expose a well-documented, machine-consumable API surface that mirrors the capabilities available in the GUI editors. The following initiatives expand the command coverage and standardize access for both humans and AI agents: + +- **Resource Inventory**: Catalogue every actionable subsystem (ROM metadata, banks, tile16 atlas, actors, palettes, scripts) and map it to a resource/action pair (e.g., `rom header set`, `dungeon room copy`, `sprite spawn`). The catalogue will live in `docs/api/z3ed-resources.yaml` and be generated from source annotations; current machine-readable coverage includes palette, overworld, rom, patch, and dungeon actions. +- **Rich Metadata**: Schemas annotate each action with structured `effects` and `returns` arrays so agents can reason about side-effects and expected outputs when constructing plans. +- **Command Introspection Endpoint**: Introduce `z3ed agent describe --resource ` to return a structured schema describing arguments, enum values, preconditions, side-effects, and example invocations. Schemas will follow JSON Schema, enabling UI tooltips and LLM prompt construction. _Prototype status (Oct 2025)_: the command now streams catalog JSON from `ResourceCatalog`, including `effects` and `returns` arrays for each action across palette, overworld, rom, patch, and dungeon resources. + ```json + { + "resources": [ + { + "resource": "rom", + "actions": [ + { + "name": "validate", + "effects": [ + "Reads ROM from disk, verifies checksum, and reports header status." + ], + "returns": [ + { "field": "report", "type": "object", "description": "Checksum + header validation summary." } + ] + } + ] + }, + { + "resource": "overworld", + "actions": [ + { + "name": "get-tile", + "returns": [ + { "field": "tile", "type": "integer", "description": "Tile id located at the supplied coordinates." } + ] + } + ] + } + ] + } + ``` +- **State Snapshot APIs**: Extend `rom` and `project` resources with `export-state` actions that emit compact JSON snapshots (bank checksums, tile hashes, palette CRCs). Snapshots will seed the LLM context and accelerate change verification. +- **Write Guard Hooks**: All mutation-oriented commands will publish `PreChange` and `PostChange` events onto an internal bus (backed by `absl::Notification` + ring buffer). The agent loop subscribes to the bus to build a change proposal timeline used in review UIs and acceptance workflows. +- **Replayable Scripts**: Standardize a TOML-based script format (`.z3edscript`) that records CLI invocations with metadata (ROM hash, duration, success). Agents can emit scripts, humans can replay them via `z3ed script run `. + +## 12. Acceptance & Review Workflow + +An explicit accept/reject system keeps humans in control while encouraging rapid agent iteration. + +### 12.1. Change Proposal Lifecycle + +1. **Draft**: Agent executes commands in a sandbox ROM (auto-cloned using `Rom::SaveToFile` with `save_new=true`). All diffs, test logs, and screenshots are attached to a proposal ID. +2. **Review**: The dashboard surfaces proposals with summary cards (changed resources, affected banks, test status). Users can open a detail view built atop the existing diff viewer, augmented with per-resource controls (accept tile, reject palette entry, etc.). +3. **Decision**: Accepting merges the delta into the primary ROM and commits associated assets. Rejecting discards the sandbox ROM and emits feedback signals (tagged reasons) that can be fed back to future LLM prompts. +4. **Archive**: Accepted proposals are archived with metadata for provenance; rejected ones are stored briefly for analytics before being pruned. + +### 12.2. UI Extensions + +- **Proposal Drawer**: Adds a right-hand drawer in the ImGui dashboard listing open proposals with filters (resource type, test pass/fail, age). +- **Inline Diff Controls**: Integrate checkboxes/buttons into the existing palette/tile hex viewers so users can cherry-pick changes without leaving the visual context. +- **Feedback Composer**: Provide quick tags (“Incorrect palette”, “Misplaced sprite”, “Regression detected”) and optional freeform text. Feedback is serialized into the agent telemetry channel. +- **Undo/Redo Enhancements**: Accepted proposals push onto the global undo stack with descriptive labels, enabling rapid rollback during exploratory sessions. + +### 12.3. Policy Configuration + +- **Gatekeeping Rules**: Define YAML-driven policies (e.g., “require passing `agent smoke` and `palette regression` suites before accept button activates”). Rules live in `.yaze/policies/agent.yaml` and are evaluated by the dashboard. +- **Access Control**: Integrate project roles so only maintainers can finalize proposals while contributors can submit drafts. +- **Telemetry Opt-In**: Provide toggles for sharing anonymized proposal statistics to improve default prompts and heuristics. + +## 13. ImGuiTestEngine Control Bridge + +Allowing an LLM to drive the ImGui UI safely requires a structured bridge between generated plans and the `ImGuiTestEngine` runtime. + +### 13.1. Bridge Architecture + +- **Test Harness API**: Expose a lightweight gRPC/IPC service (`ImGuiTestHarness`) that accepts serialized input events (click, drag, key, text), query requests (widget tree, screenshot), and expectations (assert widget text equals …). The service runs inside `yaze_test` when started with `--automation=sock`. Agents connect via domain sockets (macOS/Linux) or named pipes (Windows). +- **Command Translation Layer**: Extend `z3ed agent run` to recognize plan steps with type `imgui_action`. These steps translate to harness calls (e.g., `{ "type": "imgui_action", "action": "click", "target": "Palette/Cell[12]" }`). +- **Synchronization Primitives**: Provide `WaitForIdle`, `WaitForCondition`, and `Delay` primitives so LLMs can coordinate with frame updates. Each primitive enforces timeouts and returns explicit success/failure statuses. +- **State Queries**: Implement reflection endpoints retrieving ImGui widget hierarchy, enabling the agent to confirm UI states before issuing the next action—mirroring how `ImGuiTestEngine` DSL scripts work today. + +### 13.2. Safety & Sandboxing + +- **Read-Only Default**: Harness sessions start in read-only mode; mutation commands must explicitly request escalation after presenting a plan (triggering a UI prompt for the user to authorize). Without authorization, only `capture` and `assert` operations succeed. +- **Rate Limiting**: Cap concurrent interactions and enforce per-step quotas to prevent runaway agents. +- **Logging**: Every harness call is logged and linked to the proposal ID, with playback available inside the acceptance UI. + +### 13.3. Script Generation Strategy + +- **Template Library**: Publish a library of canonical ImGui action sequences (open file, expand tree, focus palette editor). Plans reference templates via IDs to reduce LLM token usage and improve reliability. +- **Auto-Healing**: When a widget lookup fails, the harness can suggest closest matches (Levenshtein distance) so the agent can retry with corrected IDs. +- **Hybrid Execution**: Encourage plans that mix CLI operations for bulk edits and ImGui actions for visual verification, minimizing UI-driven mutations. + +## 14. Test & Verification Strategy + +### 14.1. Layered Test Suites + +- **CLI Unit Tests**: Extend `test/cli/` with high-coverage tests for new resource handlers using sandbox ROM fixtures. +- **Harness Integration Tests**: Add `test/ui/automation/` cases that spin up the harness, replay canned plans, and validate deterministic behavior. +- **End-to-End Agent Scenarios**: Create golden scenarios (e.g., “Recolor Link tunic”, “Shift Dungeon Chest”) that exercise command + UI flows, verifying ROM diffs, UI captures, and pass/fail criteria. + +### 14.2. Continuous Verification + +- **CI Pipelines**: Introduce dedicated CI jobs for agent features, enabling `YAZE_WITH_JSON` builds, running harness smoke suites, and publishing artifacts (diffs, screenshots) on failure. +- **Nightly Regression**: Schedule nightly runs of expensive ImGui scenarios and long-running CLI scripts with hardware acceleration (Apple Metal) to detect flaky interactions. +- **Fuzzing Hooks**: Instrument command parsers with libFuzzer harnesses to catch malformed LLM output early. + +### 14.3. Telemetry-Informed Testing + +- **Flake Tracker**: Aggregate harness failures by widget/action to prioritize stabilization. +- **Adaptive Test Selection**: Use proposal metadata to select relevant regression suites dynamically (e.g., palette-focused proposals trigger palette regression tests). +- **Feedback Loop**: Feed test outcomes back into prompt engineering, e.g., annotate prompts with known flaky commands so the LLM favors safer alternatives. + +## 15. Expanded Roadmap (Phase 6+) + +### Phase 6: Agent Workflow Foundations (Planned) +- Implement resource catalogue tooling and `agent describe` schemas. +- Ship sandbox ROM workflow with proposal tracking and acceptance UI. +- Finalize ImGuiTestHarness MVP with read-only verification. +- Expand CLI surface with sprite/object manipulation commands flagged as agent-safe. + +### Phase 7: Controlled Mutation & Review (Planned) +- Enable harness mutation mode with user authorization prompts. +- Deliver inline diff controls and feedback composer UI. +- Wire policy engine for gating accept buttons. +- Launch initial telemetry dashboards (opt-in) for agent performance metrics. + +### Phase 8: Learning & Self-Improvement (Exploratory) +- Capture accept/reject rationales to train prompt selectors. +- Experiment with reinforcement signals for local models (reward accepted plans, penalize rejected ones). +- Explore collaborative agent sessions where multiple proposals merge or compete under defined heuristics. +- Investigate deterministic replay of LLM outputs for reliable regression testing. diff --git a/docs/E6-z3ed-implementation-plan.md b/docs/E6-z3ed-implementation-plan.md new file mode 100644 index 00000000..d5655817 --- /dev/null +++ b/docs/E6-z3ed-implementation-plan.md @@ -0,0 +1,56 @@ +# z3ed Agentic Workflow Implementation Plan + +_Last updated: 2025-10-01 (afternoon update)_ + +This plan decomposes the design additions (Sections 11–15 of `E6-z3ed-cli-design.md`) into actionable engineering tasks. Each workstream contains milestones, owners (TBD), blocking dependencies, and expected deliverables. + +## 1. Workstreams Overview + +| Workstream | Goal | Milestone Target | Notes | +|------------|------|------------------|-------| +| Resource Catalogue | Provide authoritative machine-readable specs for CLI resources. | Phase 6 | Schema now captures effects/returns metadata for palette/overworld/rom/patch/dungeon; automation pending. | +| Acceptance Workflow | Enable human review/approval of agent proposals in ImGui. | Phase 7 | Sandbox manager prototype landed; UI work pending. | +| ImGuiTest Bridge | Allow agents to drive ImGui via `ImGuiTestEngine`. | Phase 6 | Requires harness IPC transport. | +| Verification Pipeline | Build layered testing + CI coverage. | Phase 6+ | Integrates with harness + CLI suites. | +| Telemetry & Learning | Capture signals to improve prompts + heuristics. | Phase 8 | Optional/opt-in features. | + +## 2. Task Backlog + +| ID | Task | Workstream | Type | Status | Dependencies | +|----|------|------------|------|--------|--------------| +| RC-01 | Define schema for `ResourceCatalog` entries and implement serialization helpers. | Resource Catalogue | Code | In Progress | Palette/Overworld/ROM/Patch/Dungeon actions annotated with effects/returns; serialization covered by unit tests; CLI wiring ongoing | +| RC-02 | Auto-generate `docs/api/z3ed-resources.yaml` from command annotations. | Resource Catalogue | Tooling | Blocked | RC-01 | +| RC-03 | Implement `z3ed agent describe` CLI surface returning JSON schemas. | Resource Catalogue | Code | Prototype | `agent describe` outputs JSON; broaden resource coverage next | +| RC-04 | Integrate schema export with TUI command palette + help overlays. | Resource Catalogue | UX | Planned | RC-03 | +| AW-01 | Implement sandbox ROM cloning and tracking (`RomSandboxManager`). | Acceptance Workflow | Code | Prototype | ROM snapshot infra | +| AW-02 | Build proposal registry service storing diffs, logs, screenshots. | Acceptance Workflow | Code | Planned | AW-01 | +| AW-03 | Add ImGui drawer for proposals with accept/reject controls. | Acceptance Workflow | UX | Planned | AW-02 | +| AW-04 | Implement policy evaluation for gating accept buttons. | Acceptance Workflow | Code | Planned | AW-03 | +| IT-01 | Create `ImGuiTestHarness` IPC service embedded in `yaze_test`. | ImGuiTest Bridge | Code | Planned | Harness transport decision | +| IT-02 | Implement CLI agent step translation (`imgui_action` → harness call). | ImGuiTest Bridge | Code | Planned | IT-01 | +| IT-03 | Provide synchronization primitives (`WaitForIdle`, etc.). | ImGuiTest Bridge | Code | Planned | IT-01 | +| VP-01 | Expand CLI unit tests for new commands and sandbox flow. | Verification Pipeline | Test | Planned | RC/AW tasks | +| VP-02 | Add harness integration tests with replay scripts. | Verification Pipeline | Test | Planned | IT tasks | +| VP-03 | Create CI job running agent smoke tests with `YAZE_WITH_JSON`. | Verification Pipeline | Infra | Planned | VP-01, VP-02 | +| TL-01 | Capture accept/reject metadata and push to telemetry log. | Telemetry & Learning | Code | Planned | AW tasks | +| TL-02 | Build anonymized metrics exporter + opt-in toggle. | Telemetry & Learning | Infra | Planned | TL-01 | + +_Status Legend: Prototype · In Progress · Planned · Blocked · Done_ + +## 3. Immediate Next Steps + +1. Automate catalog export into `docs/api/z3ed-resources.yaml` and snapshot `agent describe` outputs for regression coverage (`RC-02`, `RC-03`). +2. Wire `RomSandboxManager` into proposal lifecycle (metadata persistence, cleanup) and validate with agent diff flow (`AW-01`). +3. Spike IPC options for `ImGuiTestHarness` (socket vs. HTTP vs. shared memory) and document findings. + +## 4. Open Questions + +- What serialization format should the proposal registry adopt for diff payloads (binary vs. textual vs. hybrid)? +- How should the harness authenticate escalation requests for mutation actions? +- Can we reuse existing regression test infrastructure for nightly ImGui runs or should we spin up a dedicated binary? + +## 5. References + +- `docs/E6-z3ed-cli-design.md` +- `docs/api/z3ed-resources.yaml` +- `src/cli/service/resource_catalog.h` (prototype) diff --git a/src/cli/cli_main.cc b/src/cli/cli_main.cc index 61a8bc15..1f94b4ab 100644 --- a/src/cli/cli_main.cc +++ b/src/cli/cli_main.cc @@ -1,41 +1,134 @@ +#include #include +#include #include +#include +#include #include "absl/flags/flag.h" -#include "absl/flags/parse.h" -#include "absl/status/status.h" #include "absl/strings/str_format.h" +#include "absl/strings/match.h" #include "cli/modern_cli.h" #include "cli/tui.h" +#include "yaze_config.h" ABSL_FLAG(bool, tui, false, "Launch Text User Interface"); ABSL_FLAG(std::string, rom, "", "Path to the ROM file"); -ABSL_FLAG(std::string, output, "", "Output file path"); -ABSL_FLAG(bool, verbose, false, "Enable verbose output"); -ABSL_FLAG(bool, dry_run, false, "Perform operations without making changes"); -ABSL_FLAG(bool, backup, true, "Create a backup before modifying files"); -ABSL_FLAG(std::string, test, "", "Name of the test to run"); -ABSL_FLAG(bool, show_gui, false, "Show the test engine GUI"); + +namespace { + +struct ParsedGlobals { + std::vector positional; + bool show_help = false; + bool show_version = false; + std::optional error; +}; + +ParsedGlobals ParseGlobalFlags(int argc, char* argv[]) { + ParsedGlobals result; + if (argc <= 0 || argv == nullptr) { + result.error = "Invalid argv provided"; + return result; + } + + result.positional.reserve(argc); + result.positional.push_back(argv[0]); + + bool passthrough = false; + for (int i = 1; i < argc; ++i) { + char* current = argv[i]; + std::string_view token(current); + + if (!passthrough) { + if (token == "--") { + passthrough = true; + continue; + } + + if (token == "--help" || token == "-h") { + result.show_help = true; + continue; + } + + if (token == "--version") { + result.show_version = true; + continue; + } + + if (token == "--tui") { + absl::SetFlag(&FLAGS_tui, true); + continue; + } + + if (absl::StartsWith(token, "--rom=")) { + absl::SetFlag(&FLAGS_rom, std::string(token.substr(6))); + continue; + } + + if (token == "--rom") { + if (i + 1 >= argc) { + result.error = "--rom flag requires a value"; + return result; + } + absl::SetFlag(&FLAGS_rom, std::string(argv[++i])); + continue; + } + } + + result.positional.push_back(current); + } + + return result; +} + +void PrintVersion() { + std::cout << absl::StrFormat("yaze %d.%d.%d", YAZE_VERSION_MAJOR, + YAZE_VERSION_MINOR, YAZE_VERSION_PATCH) + << std::endl; +} + +} // namespace int main(int argc, char* argv[]) { - // Parse command line flags - absl::ParseCommandLine(argc, argv); + ParsedGlobals globals = ParseGlobalFlags(argc, argv); + + if (globals.error.has_value()) { + std::cerr << "Error: " << *globals.error << std::endl; + return EXIT_FAILURE; + } + + if (globals.show_version) { + PrintVersion(); + return EXIT_SUCCESS; + } // Check if TUI mode is requested if (absl::GetFlag(FLAGS_tui)) { yaze::cli::ShowMain(); - return 0; + return EXIT_SUCCESS; + } + + yaze::cli::ModernCLI cli; + + if (globals.show_help) { + cli.PrintTopLevelHelp(); + return EXIT_SUCCESS; + } + + if (globals.positional.size() <= 1) { + cli.PrintTopLevelHelp(); + return EXIT_SUCCESS; } // Run CLI commands - yaze::cli::ModernCLI cli; - auto status = cli.Run(argc, argv); + auto status = cli.Run(static_cast(globals.positional.size()), + globals.positional.data()); if (!status.ok()) { std::cerr << "Error: " << status.message() << std::endl; - return 1; + return EXIT_FAILURE; } - return 0; + return EXIT_SUCCESS; } diff --git a/src/cli/handlers/agent.cc b/src/cli/handlers/agent.cc index 2f8b5580..3f808ffb 100644 --- a/src/cli/handlers/agent.cc +++ b/src/cli/handlers/agent.cc @@ -1,10 +1,20 @@ #include "cli/z3ed.h" #include "cli/modern_cli.h" #include "cli/service/ai_service.h" +#include "cli/service/resource_catalog.h" +#include "cli/service/rom_sandbox_manager.h" +#include "util/macro.h" #include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/ascii.h" +#include "absl/strings/match.h" +#include "absl/strings/str_format.h" +#include "absl/time/time.h" #include // For EXIT_FAILURE +#include +#include // Platform-specific includes for process management and executable path detection #if !defined(_WIN32) @@ -24,17 +34,82 @@ namespace cli { namespace { +struct DescribeOptions { + std::optional resource; + std::string format = "json"; + std::optional output_path; + std::string version = "0.1.0"; + std::optional last_updated; +}; + +absl::StatusOr ParseDescribeArgs( + const std::vector& args) { + DescribeOptions options; + for (size_t i = 0; i < args.size(); ++i) { + const std::string& token = args[i]; + std::string flag = token; + std::optional inline_value; + + if (absl::StartsWith(token, "--")) { + auto eq_pos = token.find('='); + if (eq_pos != std::string::npos) { + flag = token.substr(0, eq_pos); + inline_value = token.substr(eq_pos + 1); + } + } + + auto require_value = [&](absl::string_view flag_name) -> absl::StatusOr { + if (inline_value.has_value()) { + return *inline_value; + } + if (i + 1 >= args.size()) { + return absl::InvalidArgumentError( + absl::StrFormat("Flag %s requires a value", flag_name)); + } + return args[++i]; + }; + + if (flag == "--resource") { + ASSIGN_OR_RETURN(auto value, require_value("--resource")); + options.resource = std::move(value); + } else if (flag == "--format") { + ASSIGN_OR_RETURN(auto value, require_value("--format")); + options.format = std::move(value); + } else if (flag == "--output") { + ASSIGN_OR_RETURN(auto value, require_value("--output")); + options.output_path = std::move(value); + } else if (flag == "--version") { + ASSIGN_OR_RETURN(auto value, require_value("--version")); + options.version = std::move(value); + } else if (flag == "--last-updated") { + ASSIGN_OR_RETURN(auto value, require_value("--last-updated")); + options.last_updated = std::move(value); + } else { + return absl::InvalidArgumentError( + absl::StrFormat("Unknown flag for agent describe: %s", token)); + } + } + + options.format = absl::AsciiStrToLower(options.format); + if (options.format != "json" && options.format != "yaml") { + return absl::InvalidArgumentError("--format must be either json or yaml"); + } + + return options; +} + absl::Status HandleRunCommand(const std::vector& arg_vec, Rom& rom) { if (arg_vec.size() < 2 || arg_vec[0] != "--prompt") { return absl::InvalidArgumentError("Usage: agent run --prompt "); } std::string prompt = arg_vec[1]; - // Save a temporary copy of the ROM + // Save a sandbox copy of the ROM for proposal tracking. if (rom.is_loaded()) { - auto status = rom.SaveToFile({.save_new = true, .filename = "temp_rom.sfc"}); - if (!status.ok()) { - return status; + auto sandbox_or = RomSandboxManager::Instance().CreateSandbox( + rom, "agent-run"); + if (!sandbox_or.ok()) { + return sandbox_or.status(); } } @@ -97,8 +172,13 @@ absl::Status HandlePlanCommand(const std::vector& arg_vec) { absl::Status HandleDiffCommand(Rom& rom) { if (rom.is_loaded()) { + auto sandbox_or = RomSandboxManager::Instance().ActiveSandbox(); + if (!sandbox_or.ok()) { + return sandbox_or.status(); + } RomDiff diff_handler; - auto status = diff_handler.Run({rom.filename(), "temp_rom.sfc"}); + auto status = diff_handler.Run( + {rom.filename(), sandbox_or->rom_path.string()}); if (!status.ok()) { return status; } @@ -225,11 +305,69 @@ absl::Status HandleRevertCommand(Rom& rom) { return absl::OkStatus(); } +absl::Status HandleDescribeCommand(const std::vector& arg_vec) { + ASSIGN_OR_RETURN(auto options, ParseDescribeArgs(arg_vec)); + + const auto& catalog = ResourceCatalog::Instance(); + std::optional resource_schema; + if (options.resource.has_value()) { + auto resource_or = catalog.GetResource(*options.resource); + if (!resource_or.ok()) { + return resource_or.status(); + } + resource_schema = resource_or.value(); + } + + std::string payload; + if (options.format == "json") { + if (resource_schema.has_value()) { + payload = catalog.SerializeResource(*resource_schema); + } else { + payload = catalog.SerializeResources(catalog.AllResources()); + } + } else { + std::string last_updated = options.last_updated.has_value() + ? *options.last_updated + : absl::FormatTime("%Y-%m-%d", absl::Now(), + absl::LocalTimeZone()); + if (resource_schema.has_value()) { + std::vector schemas{*resource_schema}; + payload = catalog.SerializeResourcesAsYaml( + schemas, options.version, last_updated); + } else { + payload = catalog.SerializeResourcesAsYaml( + catalog.AllResources(), options.version, last_updated); + } + } + + if (options.output_path.has_value()) { + std::ofstream out(*options.output_path, std::ios::binary | std::ios::trunc); + if (!out.is_open()) { + return absl::InternalError(absl::StrFormat( + "Failed to open %s for writing", *options.output_path)); + } + out << payload; + out.close(); + if (!out) { + return absl::InternalError(absl::StrFormat( + "Failed to write schema to %s", *options.output_path)); + } + std::cout << absl::StrFormat("Wrote %s schema to %s", options.format, + *options.output_path) + << std::endl; + return absl::OkStatus(); + } + + std::cout << payload << std::endl; + return absl::OkStatus(); +} + } // namespace absl::Status Agent::Run(const std::vector& arg_vec) { if (arg_vec.empty()) { - return absl::InvalidArgumentError("Usage: agent [options]"); + return absl::InvalidArgumentError( + "Usage: agent [options]"); } std::string subcommand = arg_vec[0]; @@ -249,6 +387,8 @@ absl::Status Agent::Run(const std::vector& arg_vec) { return HandleCommitCommand(rom_); } else if (subcommand == "revert") { return HandleRevertCommand(rom_); + } else if (subcommand == "describe") { + return HandleDescribeCommand(subcommand_args); } else { return absl::InvalidArgumentError("Invalid subcommand for agent command."); } diff --git a/src/cli/modern_cli.cc b/src/cli/modern_cli.cc index eeae716f..3f306e62 100644 --- a/src/cli/modern_cli.cc +++ b/src/cli/modern_cli.cc @@ -6,6 +6,7 @@ #include "absl/flags/declare.h" #include "absl/strings/str_cat.h" #include "absl/strings/str_format.h" +#include "absl/strings/str_join.h" #include "app/core/asar_wrapper.h" #include "app/rom.h" @@ -59,7 +60,9 @@ void ModernCLI::SetupCommands() { commands_["agent"] = { .name = "agent", .description = "Interact with the AI agent", - .usage = "z3ed agent [options]", + .usage = "z3ed agent [options]\n" + " describe options: [--resource ] [--format json|yaml] [--output ]\n" + " [--version ] [--last-updated ]", .handler = [this](const std::vector& args) -> absl::Status { return HandleAgentCommand(args); } @@ -156,6 +159,15 @@ void ModernCLI::SetupCommands() { }; commands_["palette"] = { + .name = "palette", + .description = "Manage palette data (export/import)", + .usage = "z3ed palette [options]", + .handler = [this](const std::vector& args) -> absl::Status { + return HandlePaletteCommand(args); + } + }; + + commands_["palette export"] = { .name = "palette export", .description = "Export a palette to a file", .usage = "z3ed palette export --group --id --to ", @@ -209,12 +221,9 @@ void ModernCLI::ShowHelp() { std::cout << std::endl; std::cout << "GLOBAL FLAGS:" << std::endl; std::cout << " --tui Launch Text User Interface" << std::endl; - std::cout << " --version Show version information" << std::endl; - std::cout << " --verbose Enable verbose output" << std::endl; std::cout << " --rom= Specify ROM file to use" << std::endl; - std::cout << " --output= Specify output file path" << std::endl; - std::cout << " --dry-run Perform operations without making changes" << std::endl; - std::cout << " --backup= Create backup before modifying (default: true)" << std::endl; + std::cout << " --version Show version information" << std::endl; + std::cout << " --help Show this help message" << std::endl; std::cout << std::endl; std::cout << "COMMANDS:" << std::endl; @@ -234,31 +243,51 @@ void ModernCLI::ShowHelp() { std::cout << " z3ed help " << std::endl; } +void ModernCLI::PrintTopLevelHelp() const { + const_cast(this)->ShowHelp(); +} + absl::Status ModernCLI::Run(int argc, char* argv[]) { if (argc < 2) { ShowHelp(); return absl::OkStatus(); } - std::string command; - std::vector command_args; + std::vector args; + args.reserve(argc - 1); + for (int i = 1; i < argc; ++i) { + args.emplace_back(argv[i]); + } - if (argc >= 3) { - command = std::string(argv[1]) + " " + std::string(argv[2]); - for (int i = 3; i < argc; ++i) { - command_args.push_back(argv[i]); + const CommandInfo* command_info = nullptr; + size_t consumed_tokens = 0; + + if (args.size() >= 2) { + std::string candidate = absl::StrCat(args[0], " ", args[1]); + auto it = commands_.find(candidate); + if (it != commands_.end()) { + command_info = &it->second; + consumed_tokens = 2; } - } else { - command = argv[1]; } - auto it = commands_.find(command); - if (it == commands_.end()) { + if (command_info == nullptr && !args.empty()) { + auto it = commands_.find(args[0]); + if (it != commands_.end()) { + command_info = &it->second; + consumed_tokens = 1; + } + } + + if (command_info == nullptr) { ShowHelp(); - return absl::NotFoundError(absl::StrCat("Unknown command: ", command)); + std::string joined = args.empty() ? std::string() : absl::StrJoin(args, " "); + return absl::NotFoundError( + absl::StrCat("Unknown command: ", joined.empty() ? "" : joined)); } - return it->second.handler(command_args); + std::vector command_args(args.begin() + consumed_tokens, args.end()); + return command_info->handler(command_args); } CommandHandler* ModernCLI::GetCommandHandler(const std::string& name) { diff --git a/src/cli/modern_cli.h b/src/cli/modern_cli.h index 569c4729..1f383d46 100644 --- a/src/cli/modern_cli.h +++ b/src/cli/modern_cli.h @@ -24,6 +24,7 @@ class ModernCLI { ModernCLI(); absl::Status Run(int argc, char* argv[]); CommandHandler* GetCommandHandler(const std::string& name); + void PrintTopLevelHelp() const; std::map commands_; diff --git a/src/cli/service/resource_catalog.cc b/src/cli/service/resource_catalog.cc new file mode 100644 index 00000000..d12be923 --- /dev/null +++ b/src/cli/service/resource_catalog.cc @@ -0,0 +1,444 @@ +#include "cli/service/resource_catalog.h" + +#include + +#include "absl/strings/str_cat.h" +#include "absl/strings/str_join.h" +#include "absl/strings/string_view.h" + +namespace yaze { +namespace cli { + +namespace { + +ResourceSchema MakePaletteSchema() { + ResourceSchema schema; + schema.resource = "palette"; + schema.description = + "Palette manipulation commands covering export, import, and color editing."; + + ResourceAction export_action; + export_action.name = "export"; + export_action.synopsis = "z3ed palette export --group --id --to "; + export_action.stability = "experimental"; + export_action.arguments = { + ResourceArgument{"--group", "integer", true, "Palette group id (0-31)."}, + ResourceArgument{"--id", "integer", true, "Palette index inside the group."}, + ResourceArgument{"--to", "path", true, "Destination file path for binary export."}, + }; + export_action.effects = { + "Reads ROM palette buffer and writes binary palette data to disk."}; + + ResourceAction import_action; + import_action.name = "import"; + import_action.synopsis = + "z3ed palette import --group --id --from "; + import_action.stability = "experimental"; + import_action.arguments = { + ResourceArgument{"--group", "integer", true, "Palette group id (0-31)."}, + ResourceArgument{"--id", "integer", true, "Palette index inside the group."}, + ResourceArgument{"--from", "path", true, "Source binary palette file."}, + }; + import_action.effects = { + "Writes imported palette bytes into ROM buffer and marks project dirty."}; + + schema.actions = {export_action, import_action}; + return schema; +} + +ResourceSchema MakeRomSchema() { + ResourceSchema schema; + schema.resource = "rom"; + schema.description = "ROM validation, diffing, and snapshot helpers."; + + ResourceAction validate_action; + validate_action.name = "validate"; + validate_action.synopsis = "z3ed rom validate --rom "; + validate_action.stability = "stable"; + validate_action.arguments = { + ResourceArgument{"--rom", "path", true, "Path to ROM file configured via global flag."}, + }; + validate_action.effects = { + "Reads ROM from disk, verifies checksum, and reports header status."}; + validate_action.returns = { + {"report", "object", + "Structured validation summary with checksum and header results."}}; + + ResourceAction diff_action; + diff_action.name = "diff"; + diff_action.synopsis = "z3ed rom diff "; + diff_action.stability = "beta"; + diff_action.arguments = { + ResourceArgument{"rom_a", "path", true, "Reference ROM path."}, + ResourceArgument{"rom_b", "path", true, "Candidate ROM path."}, + }; + diff_action.effects = { + "Reads two ROM images, compares bytes, and streams differences to stdout."}; + diff_action.returns = { + {"differences", "integer", "Count of mismatched bytes between ROMs."}}; + + ResourceAction generate_action; + generate_action.name = "generate-golden"; + generate_action.synopsis = + "z3ed rom generate-golden "; + generate_action.stability = "experimental"; + generate_action.arguments = { + ResourceArgument{"rom_file", "path", true, "Source ROM to snapshot."}, + ResourceArgument{"golden_file", "path", true, "Output path for golden image."}, + }; + generate_action.effects = { + "Writes out exact ROM image for tooling baselines and diff workflows."}; + generate_action.returns = { + {"artifact", "path", "Absolute path to the generated golden image."}}; + + schema.actions = {validate_action, diff_action, generate_action}; + return schema; +} + +ResourceSchema MakePatchSchema() { + ResourceSchema schema; + schema.resource = "patch"; + schema.description = + "Patch authoring and application commands covering BPS and Asar flows."; + + ResourceAction apply_action; + apply_action.name = "apply"; + apply_action.synopsis = "z3ed patch apply "; + apply_action.stability = "beta"; + apply_action.arguments = { + ResourceArgument{"rom_file", "path", true, + "Source ROM image that will receive the patch."}, + ResourceArgument{"bps_patch", "path", true, + "BPS patch to apply to the ROM."}, + }; + apply_action.effects = { + "Loads ROM from disk, applies a BPS patch, and writes `patched.sfc`."}; + apply_action.returns = { + {"artifact", "path", + "Absolute path to the patched ROM image produced on success."}}; + + ResourceAction asar_action; + asar_action.name = "apply-asar"; + asar_action.synopsis = "z3ed patch apply-asar "; + asar_action.stability = "prototype"; + asar_action.arguments = { + ResourceArgument{"patch.asm", "path", true, + "Assembly patch consumed by the bundled Asar runtime."}, + ResourceArgument{"--rom", "path", false, + "ROM path supplied via global --rom flag."}, + }; + asar_action.effects = { + "Invokes Asar against the active ROM buffer and applies assembled changes."}; + asar_action.returns = { + {"log", "string", "Assembler diagnostics emitted during application."}}; + + ResourceAction create_action; + create_action.name = "create"; + create_action.synopsis = + "z3ed patch create --source --target --out "; + create_action.stability = "experimental"; + create_action.arguments = { + ResourceArgument{"--source", "path", true, + "Baseline ROM used when computing the patch."}, + ResourceArgument{"--target", "path", true, + "Modified ROM to diff against the baseline."}, + ResourceArgument{"--out", "path", true, + "Output path for the generated BPS patch."}, + }; + create_action.effects = { + "Compares source and target images to synthesize a distributable BPS patch."}; + create_action.returns = { + {"artifact", "path", "File system path to the generated patch."}}; + + schema.actions = {apply_action, asar_action, create_action}; + return schema; +} + +ResourceSchema MakeOverworldSchema() { + ResourceSchema schema; + schema.resource = "overworld"; + schema.description = "Overworld tile inspection and manipulation commands."; + + ResourceAction get_tile; + get_tile.name = "get-tile"; + get_tile.synopsis = "z3ed overworld get-tile --map --x --y "; + get_tile.stability = "stable"; + get_tile.arguments = { + ResourceArgument{"--map", "integer", true, "Overworld map identifier (0-63)."}, + ResourceArgument{"--x", "integer", true, "Tile x coordinate."}, + ResourceArgument{"--y", "integer", true, "Tile y coordinate."}, + }; + get_tile.returns = { + {"tile", "integer", + "Tile id located at the supplied coordinates."}}; + + ResourceAction set_tile; + set_tile.name = "set-tile"; + set_tile.synopsis = + "z3ed overworld set-tile --map --x --y --tile "; + set_tile.stability = "experimental"; + set_tile.arguments = { + ResourceArgument{"--map", "integer", true, "Overworld map identifier (0-63)."}, + ResourceArgument{"--x", "integer", true, "Tile x coordinate."}, + ResourceArgument{"--y", "integer", true, "Tile y coordinate."}, + ResourceArgument{"--tile", "integer", true, "Tile id to write."}, + }; + set_tile.effects = { + "Mutates overworld tile map and enqueues render invalidation."}; + + schema.actions = {get_tile, set_tile}; + return schema; +} + +ResourceSchema MakeDungeonSchema() { + ResourceSchema schema; + schema.resource = "dungeon"; + schema.description = "Dungeon room export and inspection utilities."; + + ResourceAction export_action; + export_action.name = "export"; + export_action.synopsis = "z3ed dungeon export "; + export_action.stability = "prototype"; + export_action.arguments = { + ResourceArgument{"room_id", "integer", true, + "Dungeon room identifier to inspect."}, + }; + export_action.effects = { + "Loads the active ROM via --rom and prints metadata for the requested room."}; + export_action.returns = { + {"metadata", "object", + "Structured room summary including blockset, spriteset, palette, and layout."}}; + + ResourceAction list_objects_action; + list_objects_action.name = "list-objects"; + list_objects_action.synopsis = "z3ed dungeon list-objects "; + list_objects_action.stability = "prototype"; + list_objects_action.arguments = { + ResourceArgument{"room_id", "integer", true, + "Dungeon room identifier whose objects should be listed."}, + }; + list_objects_action.effects = { + "Streams parsed dungeon object records for the requested room to stdout."}; + list_objects_action.returns = { + {"objects", "array", + "Collection of tile object records with ids, coordinates, and layers."}}; + + schema.actions = {export_action, list_objects_action}; + return schema; +} + +ResourceSchema MakeAgentSchema() { + ResourceSchema schema; + schema.resource = "agent"; + schema.description = + "Agent workflow helpers including planning, diffing, and schema discovery."; + + ResourceAction describe_action; + describe_action.name = "describe"; + describe_action.synopsis = "z3ed agent describe --resource "; + describe_action.stability = "prototype"; + describe_action.arguments = { + ResourceArgument{"--resource", "string", false, + "Optional resource name to filter results."}, + }; + describe_action.returns = { + {"schema", "object", + "JSON schema describing resource arguments and semantics."}}; + + schema.actions = {describe_action}; + return schema; +} + +} // namespace + +const ResourceCatalog& ResourceCatalog::Instance() { + static ResourceCatalog* instance = new ResourceCatalog(); + return *instance; +} + +ResourceCatalog::ResourceCatalog() + : resources_({MakeRomSchema(), MakePatchSchema(), MakePaletteSchema(), + MakeOverworldSchema(), MakeDungeonSchema(), MakeAgentSchema()}) {} + +absl::StatusOr ResourceCatalog::GetResource(absl::string_view name) const { + for (const auto& resource : resources_) { + if (resource.resource == name) { + return resource; + } + } + return absl::NotFoundError(absl::StrCat("Resource not found: ", name)); +} + +const std::vector& ResourceCatalog::AllResources() const { return resources_; } + +std::string ResourceCatalog::SerializeResource(const ResourceSchema& schema) const { + return SerializeResources({schema}); +} + +std::string ResourceCatalog::SerializeResources(const std::vector& schemas) const { + std::vector entries; + entries.reserve(schemas.size()); + for (const auto& resource : schemas) { + std::vector action_entries; + action_entries.reserve(resource.actions.size()); + for (const auto& action : resource.actions) { + std::vector arg_entries; + arg_entries.reserve(action.arguments.size()); + for (const auto& arg : action.arguments) { + arg_entries.push_back(absl::StrCat( + "{\"flag\":\"", EscapeJson(arg.flag), + "\",\"type\":\"", EscapeJson(arg.type), + "\",\"required\":", arg.required ? "true" : "false", + ",\"description\":\"", EscapeJson(arg.description), "\"}")); + } + std::vector effect_entries; + effect_entries.reserve(action.effects.size()); + for (const auto& effect : action.effects) { + effect_entries.push_back(absl::StrCat("\"", EscapeJson(effect), "\"")); + } + std::vector return_entries; + return_entries.reserve(action.returns.size()); + for (const auto& ret : action.returns) { + return_entries.push_back(absl::StrCat( + "{\"field\":\"", EscapeJson(ret.field), + "\",\"type\":\"", EscapeJson(ret.type), + "\",\"description\":\"", EscapeJson(ret.description), "\"}")); + } + action_entries.push_back(absl::StrCat( + "{\"name\":\"", EscapeJson(action.name), + "\",\"synopsis\":\"", EscapeJson(action.synopsis), + "\",\"stability\":\"", EscapeJson(action.stability), + "\",\"arguments\":[", absl::StrJoin(arg_entries, ","), "],", + "\"effects\":[", absl::StrJoin(effect_entries, ","), "],", + "\"returns\":[", absl::StrJoin(return_entries, ","), "]}")); + } + entries.push_back(absl::StrCat( + "{\"resource\":\"", EscapeJson(resource.resource), + "\",\"description\":\"", EscapeJson(resource.description), + "\",\"actions\":[", absl::StrJoin(action_entries, ","), "]}")); + } + return absl::StrCat("{\"resources\":[", absl::StrJoin(entries, ","), "]}"); +} + +std::string ResourceCatalog::EscapeJson(absl::string_view value) { + std::string out; + out.reserve(value.size()); + for (char c : value) { + switch (c) { + case '\\': + out += "\\\\"; + break; + case '\"': + out += "\\\""; + break; + case '\n': + out += "\\n"; + break; + case '\r': + out += "\\r"; + break; + case '\t': + out += "\\t"; + break; + default: + out += c; + break; + } + } + return out; +} + +std::string ResourceCatalog::SerializeResourcesAsYaml( + const std::vector& schemas, + absl::string_view version, + absl::string_view last_updated) const { + std::string out; + absl::StrAppend(&out, "# Auto-generated resource catalogue\n"); + absl::StrAppend(&out, "version: ", EscapeYaml(version), "\n"); + absl::StrAppend(&out, "last_updated: ", EscapeYaml(last_updated), "\n"); + absl::StrAppend(&out, "resources:\n"); + + for (const auto& resource : schemas) { + absl::StrAppend(&out, " - name: ", EscapeYaml(resource.resource), "\n"); + absl::StrAppend(&out, " description: ", EscapeYaml(resource.description), "\n"); + + if (resource.actions.empty()) { + absl::StrAppend(&out, " actions: []\n"); + continue; + } + + absl::StrAppend(&out, " actions:\n"); + for (const auto& action : resource.actions) { + absl::StrAppend(&out, " - name: ", EscapeYaml(action.name), "\n"); + absl::StrAppend(&out, " synopsis: ", EscapeYaml(action.synopsis), "\n"); + absl::StrAppend(&out, " stability: ", EscapeYaml(action.stability), "\n"); + + if (action.arguments.empty()) { + absl::StrAppend(&out, " args: []\n"); + } else { + absl::StrAppend(&out, " args:\n"); + for (const auto& arg : action.arguments) { + absl::StrAppend(&out, " - flag: ", EscapeYaml(arg.flag), "\n"); + absl::StrAppend(&out, " type: ", EscapeYaml(arg.type), "\n"); + absl::StrAppend(&out, " required: ", arg.required ? "true\n" : "false\n"); + absl::StrAppend(&out, " description: ", EscapeYaml(arg.description), "\n"); + } + } + + if (action.effects.empty()) { + absl::StrAppend(&out, " effects: []\n"); + } else { + absl::StrAppend(&out, " effects:\n"); + for (const auto& effect : action.effects) { + absl::StrAppend(&out, " - ", EscapeYaml(effect), "\n"); + } + } + + if (action.returns.empty()) { + absl::StrAppend(&out, " returns: []\n"); + } else { + absl::StrAppend(&out, " returns:\n"); + for (const auto& ret : action.returns) { + absl::StrAppend(&out, " - field: ", EscapeYaml(ret.field), "\n"); + absl::StrAppend(&out, " type: ", EscapeYaml(ret.type), "\n"); + absl::StrAppend(&out, " description: ", EscapeYaml(ret.description), "\n"); + } + } + } + } + + return out; +} + +std::string ResourceCatalog::EscapeYaml(absl::string_view value) { + std::string out; + out.reserve(value.size() + 2); + out.push_back('"'); + for (char c : value) { + switch (c) { + case '\\': + out += "\\\\"; + break; + case '"': + out += "\\\""; + break; + case '\n': + out += "\\n"; + break; + case '\r': + out += "\\r"; + break; + case '\t': + out += "\\t"; + break; + default: + out.push_back(c); + break; + } + } + out.push_back('"'); + return out; +} + +} // namespace cli +} // namespace yaze diff --git a/src/cli/service/resource_catalog.h b/src/cli/service/resource_catalog.h new file mode 100644 index 00000000..e261a4ba --- /dev/null +++ b/src/cli/service/resource_catalog.h @@ -0,0 +1,71 @@ +#ifndef YAZE_SRC_CLI_SERVICE_RESOURCE_CATALOG_H_ +#define YAZE_SRC_CLI_SERVICE_RESOURCE_CATALOG_H_ + +#include +#include + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" + +namespace yaze { +namespace cli { + +struct ResourceArgument { + std::string flag; + std::string type; + bool required; + std::string description; +}; + +struct ResourceAction { + std::string name; + std::string synopsis; + std::string stability; + std::vector arguments; + std::vector effects; + struct ReturnValue { + std::string field; + std::string type; + std::string description; + }; + std::vector returns; +}; + +struct ResourceSchema { + std::string resource; + std::string description; + std::vector actions; +}; + +// ResourceCatalog exposes a machine-readable description of CLI resources so that +// both humans and AI agents can introspect capabilities at runtime. +class ResourceCatalog { + public: + static const ResourceCatalog& Instance(); + + absl::StatusOr GetResource(absl::string_view name) const; + const std::vector& AllResources() const; + + // Serialize helpers for `z3ed agent describe`. These return compact JSON + // strings so we avoid introducing a hard dependency on nlohmann::json. + std::string SerializeResource(const ResourceSchema& schema) const; + std::string SerializeResources(const std::vector& schemas) const; + std::string SerializeResourcesAsYaml( + const std::vector& schemas, + absl::string_view version, + absl::string_view last_updated) const; + + private: + ResourceCatalog(); + + static std::string EscapeJson(absl::string_view value); + static std::string EscapeYaml(absl::string_view value); + + std::vector resources_; +}; + +} // namespace cli +} // namespace yaze + +#endif // YAZE_SRC_CLI_SERVICE_RESOURCE_CATALOG_H_ diff --git a/src/cli/service/rom_sandbox_manager.cc b/src/cli/service/rom_sandbox_manager.cc new file mode 100644 index 00000000..c82d2961 --- /dev/null +++ b/src/cli/service/rom_sandbox_manager.cc @@ -0,0 +1,212 @@ +#include "cli/service/rom_sandbox_manager.h" + +#include +#include + +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/str_cat.h" +#include "absl/strings/str_format.h" +#include "absl/time/time.h" + +#include "util/macro.h" + +namespace yaze { +namespace cli { + +namespace { + +std::filesystem::path DetermineDefaultRoot() { + if (const char* env_root = std::getenv("YAZE_SANDBOX_ROOT")) { + return std::filesystem::path(env_root); + } + std::error_code ec; + auto temp_dir = std::filesystem::temp_directory_path(ec); + if (ec) { + // Fallback to current working directory if temp is unavailable. + return std::filesystem::current_path() / "yaze" / "sandboxes"; + } + return temp_dir / "yaze" / "sandboxes"; +} + +std::filesystem::path ResolveUniqueDirectory( + const std::filesystem::path& root, + absl::string_view id) { + return root / std::string(id); +} + +} // namespace + +RomSandboxManager& RomSandboxManager::Instance() { + static RomSandboxManager* instance = new RomSandboxManager(); + return *instance; +} + +RomSandboxManager::RomSandboxManager() + : root_directory_(DetermineDefaultRoot()) {} + +void RomSandboxManager::SetRootDirectory(const std::filesystem::path& root) { + std::lock_guard lock(mutex_); + root_directory_ = root; + (void)EnsureRootExistsLocked(); +} + +const std::filesystem::path& RomSandboxManager::RootDirectory() const { + return root_directory_; +} + +absl::Status RomSandboxManager::EnsureRootExistsLocked() { + std::error_code ec; + if (!std::filesystem::exists(root_directory_, ec)) { + if (!std::filesystem::create_directories(root_directory_, ec) && ec) { + return absl::InternalError(absl::StrCat( + "Failed to create sandbox root at ", root_directory_.string(), + ": ", ec.message())); + } + } + return absl::OkStatus(); +} + +std::string RomSandboxManager::GenerateSandboxIdLocked() { + absl::Time now = absl::Now(); + std::string time_component = absl::FormatTime("%Y%m%dT%H%M%S", now, + absl::LocalTimeZone()); + ++sequence_; + return absl::StrCat(time_component, "-", sequence_); +} + +absl::StatusOr +RomSandboxManager::CreateSandbox(Rom& rom, absl::string_view description) { + if (!rom.is_loaded()) { + return absl::FailedPreconditionError( + "Cannot create sandbox: ROM is not loaded"); + } + + std::filesystem::path source_path(rom.filename()); + if (source_path.empty()) { + return absl::FailedPreconditionError( + "Cannot create sandbox: ROM filename is empty"); + } + + std::unique_lock lock(mutex_); + RETURN_IF_ERROR(EnsureRootExistsLocked()); + + std::string id = GenerateSandboxIdLocked(); + std::filesystem::path sandbox_dir = + ResolveUniqueDirectory(root_directory_, id); + lock.unlock(); + + std::error_code ec; + if (!std::filesystem::create_directories(sandbox_dir, ec) && ec) { + return absl::InternalError(absl::StrCat( + "Failed to create sandbox directory at ", sandbox_dir.string(), + ": ", ec.message())); + } + + std::filesystem::path sandbox_rom_path = sandbox_dir / source_path.filename(); + + Rom::SaveSettings settings; + settings.filename = sandbox_rom_path.string(); + settings.save_new = false; + settings.backup = false; + settings.z3_save = true; + + absl::Status save_status = rom.SaveToFile(settings); + if (!save_status.ok()) { + std::error_code cleanup_ec; + std::filesystem::remove_all(sandbox_dir, cleanup_ec); + return save_status; + } + + lock.lock(); + sandboxes_[id] = SandboxMetadata{ + .id = id, + .directory = sandbox_dir, + .rom_path = sandbox_rom_path, + .source_rom = source_path.string(), + .description = std::string(description), + .created_at = absl::Now(), + }; + active_sandbox_id_ = id; + + return sandboxes_.at(id); +} + +absl::StatusOr +RomSandboxManager::ActiveSandbox() const { + std::lock_guard lock(mutex_); + if (!active_sandbox_id_.has_value()) { + return absl::NotFoundError("No active sandbox"); + } + auto it = sandboxes_.find(*active_sandbox_id_); + if (it == sandboxes_.end()) { + return absl::NotFoundError("Active sandbox metadata missing"); + } + return it->second; +} + +absl::StatusOr +RomSandboxManager::ActiveSandboxRomPath() const { + ASSIGN_OR_RETURN(auto meta, ActiveSandbox()); + return meta.rom_path; +} + +std::vector +RomSandboxManager::ListSandboxes() const { + std::lock_guard lock(mutex_); + std::vector list; + list.reserve(sandboxes_.size()); + for (const auto& [_, metadata] : sandboxes_) { + list.push_back(metadata); + } + std::sort(list.begin(), list.end(), + [](const SandboxMetadata& a, const SandboxMetadata& b) { + return a.created_at < b.created_at; + }); + return list; +} + +absl::Status RomSandboxManager::RemoveSandbox(const std::string& id) { + std::lock_guard lock(mutex_); + auto it = sandboxes_.find(id); + if (it == sandboxes_.end()) { + return absl::NotFoundError("Sandbox not found"); + } + std::error_code ec; + std::filesystem::remove_all(it->second.directory, ec); + if (ec) { + return absl::InternalError(absl::StrCat( + "Failed to remove sandbox directory: ", ec.message())); + } + sandboxes_.erase(it); + if (active_sandbox_id_.has_value() && *active_sandbox_id_ == id) { + active_sandbox_id_.reset(); + } + return absl::OkStatus(); +} + +absl::StatusOr RomSandboxManager::CleanupOlderThan(absl::Duration max_age) { + std::vector to_remove; + { + std::lock_guard lock(mutex_); + absl::Time threshold = absl::Now() - max_age; + for (const auto& [id, metadata] : sandboxes_) { + if (metadata.created_at < threshold) { + to_remove.push_back(id); + } + } + } + + int removed = 0; + for (const auto& id : to_remove) { + absl::Status status = RemoveSandbox(id); + if (!status.ok()) { + return status; + } + ++removed; + } + return removed; +} + +} // namespace cli +} // namespace yaze diff --git a/src/cli/service/rom_sandbox_manager.h b/src/cli/service/rom_sandbox_manager.h new file mode 100644 index 00000000..d31d5f5c --- /dev/null +++ b/src/cli/service/rom_sandbox_manager.h @@ -0,0 +1,92 @@ +#ifndef YAZE_SRC_CLI_SERVICE_ROM_SANDBOX_MANAGER_H_ +#define YAZE_SRC_CLI_SERVICE_ROM_SANDBOX_MANAGER_H_ + +#include +#include +#include +#include +#include +#include + +#include "absl/base/thread_annotations.h" +#include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/string_view.h" +#include "absl/time/time.h" + +#include "app/rom.h" + +namespace yaze { +namespace cli { + +// RomSandboxManager coordinates creation and lifecycle management of sandboxed +// ROM copies. Agent workflows operate on sandboxes so that proposals can be +// reviewed before landing in the primary project ROM. The manager currently +// tracks sandboxes in-memory for the running process and persists files to a +// configurable root directory on disk. +class RomSandboxManager { + public: + struct SandboxMetadata { + std::string id; + std::filesystem::path directory; + std::filesystem::path rom_path; + std::string source_rom; + std::string description; + absl::Time created_at; + }; + + static RomSandboxManager& Instance(); + + // Set the root directory used for new sandboxes. Must be called before any + // sandboxes are created. If not set, a default rooted at the system temporary + // directory is used (or the value of the YAZE_SANDBOX_ROOT environment + // variable when present). + void SetRootDirectory(const std::filesystem::path& root); + + const std::filesystem::path& RootDirectory() const; + + // Creates a new sandbox by copying the provided ROM into a unique directory + // under the root. The new sandbox becomes the active sandbox for the current + // process. Metadata is returned describing the sandbox on success. + absl::StatusOr CreateSandbox(Rom& rom, + absl::string_view description); + + // Returns the metadata for the active sandbox if one exists. + absl::StatusOr ActiveSandbox() const; + + // Returns the absolute path to the active sandbox ROM copy. Equivalent to + // ActiveSandbox()->rom_path but with more descriptive errors when unset. + absl::StatusOr ActiveSandboxRomPath() const; + + // List all sandboxes tracked during the current process lifetime. This will + // include the active sandbox (if any) and previously created sandboxes that + // have not been cleaned up. + std::vector ListSandboxes() const; + + // Removes the sandbox identified by |id| from the index and deletes its on + // disk directory. If the sandbox is currently active the active sandbox is + // cleared. Missing sandboxes result in a NotFound status. + absl::Status RemoveSandbox(const std::string& id); + + // Deletes any sandboxes that are older than |max_age|, returning the number + // of sandboxes removed. This is currently best-effort; individual removals + // may produce errors which are aggregated into the returned status. + absl::StatusOr CleanupOlderThan(absl::Duration max_age); + + private: + RomSandboxManager(); + + absl::Status EnsureRootExistsLocked(); + std::string GenerateSandboxIdLocked(); + + std::filesystem::path root_directory_; + mutable std::mutex mutex_; + std::unordered_map sandboxes_; + std::optional active_sandbox_id_; + int sequence_ ABSL_GUARDED_BY(mutex_) = 0; +}; + +} // namespace cli +} // namespace yaze + +#endif // YAZE_SRC_CLI_SERVICE_ROM_SANDBOX_MANAGER_H_ diff --git a/src/cli/z3ed.cmake b/src/cli/z3ed.cmake index c18359a5..a26c998c 100644 --- a/src/cli/z3ed.cmake +++ b/src/cli/z3ed.cmake @@ -43,6 +43,8 @@ add_executable( cli/handlers/project.cc cli/handlers/agent.cc cli/service/ai_service.cc + cli/service/resource_catalog.cc + cli/service/rom_sandbox_manager.cc cli/service/gemini_ai_service.cc app/rom.cc app/core/project.cc diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 1f8a1978..8fdd37da 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -29,6 +29,7 @@ if(YAZE_BUILD_TESTS AND NOT YAZE_BUILD_TESTS STREQUAL "OFF") # Unit Tests unit/core/asar_wrapper_test.cc unit/core/hex_test.cc + unit/cli/resource_catalog_test.cc unit/rom/rom_test.cc unit/gfx/snes_tile_test.cc unit/gfx/compression_test.cc @@ -41,6 +42,9 @@ if(YAZE_BUILD_TESTS AND NOT YAZE_BUILD_TESTS STREQUAL "OFF") unit/zelda3/sprite_position_test.cc unit/zelda3/test_dungeon_objects.cc unit/zelda3/dungeon_component_unit_test.cc + + # CLI Services (for catalog serialization tests) + ../src/cli/service/resource_catalog.cc # Integration Tests integration/asar_integration_test.cc @@ -79,6 +83,7 @@ if(YAZE_BUILD_TESTS AND NOT YAZE_BUILD_TESTS STREQUAL "OFF") # Unit Tests unit/core/asar_wrapper_test.cc unit/core/hex_test.cc + unit/cli/resource_catalog_test.cc unit/rom/rom_test.cc unit/gfx/snes_tile_test.cc unit/gfx/compression_test.cc @@ -91,6 +96,9 @@ if(YAZE_BUILD_TESTS AND NOT YAZE_BUILD_TESTS STREQUAL "OFF") unit/zelda3/sprite_position_test.cc unit/zelda3/test_dungeon_objects.cc unit/zelda3/dungeon_component_unit_test.cc + + # CLI Services (for catalog serialization tests) + ../src/cli/service/resource_catalog.cc # Integration Tests integration/asar_integration_test.cc diff --git a/test/unit/cli/resource_catalog_test.cc b/test/unit/cli/resource_catalog_test.cc new file mode 100644 index 00000000..6a1f8c1b --- /dev/null +++ b/test/unit/cli/resource_catalog_test.cc @@ -0,0 +1,93 @@ +#include "cli/service/resource_catalog.h" + +#include +#include + +#include "gtest/gtest.h" + +namespace yaze { +namespace cli { +namespace { + +TEST(ResourceCatalogTest, SerializeResourceIncludesReturnsArray) { + const auto& catalog = ResourceCatalog::Instance(); + auto overworld_schema = catalog.GetResource("overworld"); + ASSERT_TRUE(overworld_schema.ok()); + + std::string output = catalog.SerializeResource(overworld_schema.value()); + EXPECT_NE(output.find("\"resources\""), std::string::npos); + EXPECT_NE(output.find("\"returns\":"), std::string::npos); + EXPECT_NE(output.find("\"tile\""), std::string::npos); +} + +TEST(ResourceCatalogTest, SerializeAllResourcesIncludesAgentDescribeMetadata) { + const auto& catalog = ResourceCatalog::Instance(); + std::string output = catalog.SerializeResources(catalog.AllResources()); + + EXPECT_NE(output.find("\"agent\""), std::string::npos); + EXPECT_NE(output.find("\"effects\":"), std::string::npos); + EXPECT_NE(output.find("\"returns\":"), std::string::npos); +} + +TEST(ResourceCatalogTest, RomSchemaExposesActionsAndMetadata) { + const auto& catalog = ResourceCatalog::Instance(); + auto rom_schema = catalog.GetResource("rom"); + ASSERT_TRUE(rom_schema.ok()); + + const auto& actions = rom_schema->actions; + ASSERT_EQ(actions.size(), 3); + EXPECT_EQ(actions[0].name, "validate"); + EXPECT_FALSE(actions[0].effects.empty()); + EXPECT_FALSE(actions[0].returns.empty()); + EXPECT_EQ(actions[1].name, "diff"); + EXPECT_EQ(actions[2].name, "generate-golden"); +} + +TEST(ResourceCatalogTest, PatchSchemaIncludesAsarAndCreateActions) { + const auto& catalog = ResourceCatalog::Instance(); + auto patch_schema = catalog.GetResource("patch"); + ASSERT_TRUE(patch_schema.ok()); + + const auto& actions = patch_schema->actions; + ASSERT_GE(actions.size(), 3); + EXPECT_EQ(actions[0].name, "apply"); + EXPECT_FALSE(actions[0].returns.empty()); + + auto has_asar = std::find_if(actions.begin(), actions.end(), [](const auto& action) { + return action.name == "apply-asar"; + }); + EXPECT_NE(has_asar, actions.end()); + + auto has_create = std::find_if(actions.begin(), actions.end(), [](const auto& action) { + return action.name == "create"; + }); + EXPECT_NE(has_create, actions.end()); +} + +TEST(ResourceCatalogTest, DungeonSchemaListsMetadataAndObjectsReturns) { + const auto& catalog = ResourceCatalog::Instance(); + auto dungeon_schema = catalog.GetResource("dungeon"); + ASSERT_TRUE(dungeon_schema.ok()); + + const auto& actions = dungeon_schema->actions; + ASSERT_EQ(actions.size(), 2); + EXPECT_EQ(actions[0].name, "export"); + EXPECT_FALSE(actions[0].returns.empty()); + EXPECT_EQ(actions[1].name, "list-objects"); + EXPECT_FALSE(actions[1].returns.empty()); +} + +TEST(ResourceCatalogTest, YamlSerializationIncludesMetadataAndActions) { + const auto& catalog = ResourceCatalog::Instance(); + std::string yaml = catalog.SerializeResourcesAsYaml( + catalog.AllResources(), "0.1.0", "2025-10-01"); + + EXPECT_NE(yaml.find("version: \"0.1.0\""), std::string::npos); + EXPECT_NE(yaml.find("name: \"patch\""), std::string::npos); + EXPECT_NE(yaml.find("effects:"), std::string::npos); + EXPECT_NE(yaml.find("returns:"), std::string::npos); +} + +} // namespace +} // namespace cli +} // namespace yaze