diff --git a/README.md b/README.md index 1985bec..a674c61 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ SQL migration to `db/migrations/`, and the resource route to `config/routes.cr`. | `amber assets check` | Supported | Verify manifest, bytes, integrity, MIME, and compressed output without changing it | | `amber new APP --type native` | Preview | Not part of the beta platform guarantee | | `amber setup:lsp` | Available | Configure the bundled diagnostics LSP | +| `amber setup:agent` (`amber agent`) | Available | Install optional Claude Code and Codex feedback hooks | Run `amber --help` or `amber COMMAND --help` for command syntax. The detailed [web-app walkthrough](docs/BETA_WEB_APP.md) and @@ -177,6 +178,18 @@ amber setup:lsp See the [LSP setup guide](https://github.com/amberframework/amber/blob/v2.0.0-beta.5/docs/guides/lsp-setup.md). +## AI agent setup + +From an Amber V2 project, run `amber setup:agent` to install Claude Code and +Codex hooks, the project-local `bin/amber-agent-hook`, agent instructions, and +Amber LSP discovery. Existing hook settings and instructions are merged; the +command is safe to run again. The post-edit hook formats and checks only the +changed Crystal file with `amber-lsp --check`; the stop hook asks +`crystal-alpha watch build` for compiler feedback, or checks the target in +`shard.yml` with `--no-codegen` when no watcher is running. Install +`crystal-alpha` and `amber-lsp` for the full loop. Codex may ask you to trust +new project hooks before it runs them. + ## Contributing ```bash diff --git a/spec/amber_lsp/check_file_for_diagnostics_spec.cr b/spec/amber_lsp/check_file_for_diagnostics_spec.cr new file mode 100644 index 0000000..220cdc7 --- /dev/null +++ b/spec/amber_lsp/check_file_for_diagnostics_spec.cr @@ -0,0 +1,34 @@ +require "./spec_helper" +require "../../src/amber_lsp/rules/controllers/naming_rule" +require "../../src/amber_lsp/check_file_for_diagnostics" + +describe AmberLSP::CheckFileForDiagnostics do + it "prints a file and line for a real rule violation" do + with_tempdir do |project| + Dir.mkdir_p(File.join(project, "src/controllers")) + file_path = File.join(project, "src/controllers/users_controller.cr") + File.write(file_path, "class UsersHandler < Amber::Controller::Base\nend\n") + output = IO::Memory.new + errors = IO::Memory.new + previous_directory = Dir.current + begin + Dir.cd(project) + status = AmberLSP::CheckFileForDiagnostics.new(file_path, output, errors).perform + status.should eq(1) + output.to_s.should contain("#{file_path}:1: error: amber/controller-naming") + errors.to_s.should be_empty + ensure + Dir.cd(previous_directory) + end + end + end + + it "reports a missing file as a check error" do + with_tempdir do |project| + errors = IO::Memory.new + status = AmberLSP::CheckFileForDiagnostics.new(File.join(project, "missing.cr"), IO::Memory.new, errors).perform + status.should eq(2) + errors.to_s.should contain("file not found") + end + end +end diff --git a/spec/commands/setup_agent_spec.cr b/spec/commands/setup_agent_spec.cr new file mode 100644 index 0000000..ef35252 --- /dev/null +++ b/spec/commands/setup_agent_spec.cr @@ -0,0 +1,327 @@ +require "../amber_cli_spec" +require "../../src/amber_cli/commands/setup_agent" + +class RecordSetupAgentMessages < AmberCLI::Commands::SetupAgentCommand + getter list_of_info_messages : Array(String) = [] of String + + protected def info(message : String) + @list_of_info_messages << message + end +end + +describe "amber setup:agent" do + it "registers the setup command and short alias" do + AmberCLI::Core::CommandRegistry.find_command("setup:agent").should_not be_nil + AmberCLI::Core::CommandRegistry.find_command("agent").should eq(AmberCLI::Core::CommandRegistry.find_command("setup:agent")) + end + + it "resolves the alpha override, aliases, and stock compiler in order" do + calls = [] of String + lookup = ->(command : String) do + calls << command + {"acrystal" => "/tools/acrystal", "crystal" => "/tools/crystal"}[command]? + end + + override = AmberCLI::Agent::ResolveCompilerForAgentLoop.new("/custom/alpha", lookup) + override.perform.should eq("/custom/alpha") + calls.should be_empty + + alias_choice = AmberCLI::Agent::ResolveCompilerForAgentLoop.new(nil, lookup) + alias_choice.perform.should eq("/tools/acrystal") + alias_choice.stock_compiler?.should be_false + calls.should eq(["crystal-alpha", "acrystal"]) + + stock = AmberCLI::Agent::ResolveCompilerForAgentLoop.new(nil, ->(command : String) { command == "crystal" ? "/tools/crystal" : nil }) + stock.perform.should eq("/tools/crystal") + stock.stock_compiler?.should be_true + end + + it "merges hooks while preserving unrelated settings and remains idempotent" do + existing = %({"permissions":{"allow":["Bash(git status)"]},"hooks":{"PreToolUse":[{"matcher":"Edit|Write|MultiEdit|NotebookEdit","extra":"stay","hooks":[{"type":"command","command":"bin/existing-hook","timeout":42}]}],"SessionStart":[{"hooks":[{"type":"command","command":"bin/start-hook"}]}]}}) + first = AmberCLI::Agent::MergeAgentHooksIntoSettings.new(existing).perform + second = AmberCLI::Agent::MergeAgentHooksIntoSettings.new(first).perform + + second.should eq(first) + first.should contain("Bash(git status)") + first.should contain("bin/existing-hook") + first.should contain("bin/start-hook") + first.should contain(%("extra": "stay")) + first.should contain(%("timeout": 42)) + ["pre", "post", "stop"].each do |event| + first.scan(/bin\/amber-agent-hook #{event}/).size.should eq(1) + end + end + + it "installs the loop into a project without changing existing instructions twice" do + SpecHelper.within_temp_directory do |project| + Dir.mkdir_p("src") + File.write("src/custom_entry.cr", "puts :ok\n") + File.write("shard.yml", "name: my_app\ntargets:\n app_entry:\n main: src/custom_entry.cr\n") + File.write("CLAUDE.md", "# Existing Claude instructions\n") + File.write("AGENTS.md", "# Existing Codex instructions\n") + Dir.mkdir_p(".claude") + Dir.mkdir_p(".codex") + File.write(".claude/settings.json", %({"permissions":{"allow":["Read"]}})) + File.write(".codex/hooks.json", %({"description":"keep this","hooks":{"SessionStart":[]}})) + + command = RecordSetupAgentMessages.new("setup:agent") + command.execute + first_claude = File.read(".claude/settings.json") + first_codex = File.read(".codex/hooks.json") + first_instructions = File.read("CLAUDE.md") + first_script = File.read("bin/amber-agent-hook") + command.execute + + File.read(".claude/settings.json").should eq(first_claude) + File.read(".codex/hooks.json").should eq(first_codex) + first_claude.should contain(%("allow": [)) + first_codex.should contain("keep this") + first_codex.should contain(%("SessionStart": [])) + File.read("CLAUDE.md").should eq(first_instructions) + File.read("AGENTS.md").scan(/amber-agent-loop:start/).size.should eq(1) + first_instructions.should contain("# Existing Claude instructions") + first_instructions.should contain("crystal-alpha spec --affected") + first_script.should contain("build --no-codegen 'src/custom_entry.cr'") + File.file?(".lsp.json").should be_true + File.info("bin/amber-agent-hook").permissions.to_i.&(0o111).should_not eq(0) + command.list_of_info_messages.count("Updated: bin/amber-agent-hook").should eq(1) + end + end + + it "runs pre, post, and stop against a fake compiler and LSP" do + SpecHelper.within_temp_directory do |project| + Dir.mkdir_p("src") + File.write("src/my app.cr", "puts :ok\n") + File.write("shard.yml", "name: my_app\ntargets:\n my_app:\n main: src/my_app.cr\n") + File.write("src/my_app.cr", "puts :ok\n") + AmberCLI::Commands::SetupAgentCommand.new("setup:agent").execute + + tools = File.join(project, "fake-tools") + Dir.mkdir_p(tools) + compiler = File.join(tools, "crystal-alpha") + lsp = File.join(tools, "amber-lsp") + log = File.join(project, "calls.log") + File.write(compiler, "#!/bin/sh\nprintf 'compiler %s\\n' \"$*\" >> \"$TEST_COMMAND_LOG\"\nif [ \"$*\" = 'watch build' ]; then\n [ \"${TEST_WATCH_EXIT:-0}\" -eq 0 ] || echo 'watch build failed'\n exit \"${TEST_WATCH_EXIT:-0}\"\nfi\n[ \"${TEST_FALLBACK_EXIT:-0}\" -eq 0 ] || echo 'fallback failed'\nexit \"${TEST_FALLBACK_EXIT:-0}\"\n") + File.write(lsp, "#!/bin/sh\nprintf 'lsp %s\\n' \"$*\" >> \"$TEST_COMMAND_LOG\"\nif [ \"${TEST_LSP_EXIT:-0}\" -ne 0 ]; then\n echo 'lsp violation'\n exit \"$TEST_LSP_EXIT\"\nfi\n") + File.chmod(compiler, 0o755) + File.chmod(lsp, 0o755) + File.write(".lsp.json", {"amber" => {"command" => lsp}}.to_pretty_json + "\n") + path = "#{tools}:/usr/bin:/bin" + hook = File.join(project, "bin/amber-agent-hook") + + run_hook = ->(event : String, payload : String, watch_exit : String, fallback_exit : String) do + output = IO::Memory.new + errors = IO::Memory.new + status = Process.run(hook, [event], input: IO::Memory.new(payload), output: output, error: errors, + env: {"PATH" => path, "TEST_COMMAND_LOG" => log, "TEST_WATCH_EXIT" => watch_exit, "TEST_FALLBACK_EXIT" => fallback_exit}) + {status.exit_code, output.to_s, errors.to_s} + end + + run_hook.call("pre", "{}", "0", "0")[0].should eq(0) + edited_file = File.join(project, "src/my app.cr") + run_hook.call("post", {"tool_input" => {"file_path" => edited_file}}.to_json, "0", "0")[0].should eq(0) + calls = File.read(log) + calls.should contain("compiler watch hold") + calls.should contain("compiler tool format #{edited_file}") + calls.should contain("lsp --check #{edited_file}") + calls.should_not contain("compiler watch build") + + quoted_file = File.join(project, %(src/odd"file.cr)) + File.write(quoted_file, "puts :ok\n") + run_hook.call("post", {"tool_input" => {"file_path" => quoted_file}}.to_json, "0", "0")[0].should eq(0) + File.read(log).should contain("compiler tool format #{quoted_file}") + + previous_calls = File.read(log) + run_hook.call("post", {"tool_input" => {"file_path" => "README.md"}}.to_json, "0", "0")[0].should eq(0) + File.read(log).should eq(previous_calls) + + another_file = File.join(project, "src/another_file.cr") + File.write(another_file, "puts :ok\n") + patch = "*** Begin Patch\n*** Update File: src/my app.cr\n*** Update File: src/another_file.cr\n*** End Patch" + codex_payload = {"tool_name" => "apply_patch", "tool_input" => {"command" => patch}}.to_json + run_hook.call("post", codex_payload, "0", "0")[0].should eq(0) + codex_calls = File.read(log) + codex_calls.should match(/compiler tool format .*\/src\/another_file\.cr/) + codex_calls.should match(/lsp --check .*\/src\/another_file\.cr/) + + diagnostic_output = IO::Memory.new + diagnostic_errors = IO::Memory.new + diagnostic_status = Process.run(hook, ["post"], input: IO::Memory.new({"tool_input" => {"file_path" => edited_file}}.to_json), + output: diagnostic_output, error: diagnostic_errors, + env: {"PATH" => path, "TEST_COMMAND_LOG" => log, "TEST_LSP_EXIT" => "1"}) + diagnostic_status.exit_code.should eq(2) + diagnostic_errors.to_s.should contain("lsp violation") + + failed = run_hook.call("stop", "{}", "1", "0") + failed[0].should eq(2) + failed[2].should contain("watch build failed") + File.read(log).should_not contain("compiler build --no-codegen") + + fallback = run_hook.call("stop", "{}", "2", "0") + fallback[0].should eq(0) + File.read(log).should contain("compiler build --no-codegen src/my_app.cr") + + failed_fallback = run_hook.call("stop", "{}", "2", "1") + failed_fallback[0].should eq(2) + failed_fallback[2].should contain("fallback failed") + + run_hook.call("pre", "{}", "0", "2")[0].should eq(0) + + File.rename(compiler, File.join(tools, "acrystal")) + run_hook.call("pre", "{}", "0", "0")[0].should eq(0) + File.rename(File.join(tools, "acrystal"), File.join(tools, "crystal")) + stock_result = run_hook.call("stop", "{}", "0", "0") + stock_result[0].should eq(0) + stock_result[2].should contain("fast rebuilds need crystal-alpha") + + override_compiler = File.join(tools, "custom-alpha") + File.write(override_compiler, "#!/bin/sh\nprintf 'override %s\\n' \"$*\" >> \"$TEST_COMMAND_LOG\"\n") + File.chmod(override_compiler, 0o755) + override_status = Process.run(hook, ["pre"], input: IO::Memory.new("{}"), output: IO::Memory.new, + error: IO::Memory.new, env: {"PATH" => path, "CRYSTAL_ALPHA" => override_compiler, "TEST_COMMAND_LOG" => log}) + override_status.exit_code.should eq(0) + File.read(log).should contain("override watch hold") + end + end + + it "uses the no-targets shard name as the main file" do + SpecHelper.within_temp_directory do + Dir.mkdir_p("src") + File.write("shard.yml", "name: fallback_app\nversion: 0.1.0\n") + File.write("src/fallback_app.cr", "puts :ok\n") + + AmberCLI::Commands::SetupAgentCommand.new("setup:agent").execute + + File.read("bin/amber-agent-hook").should contain("build --no-codegen 'src/fallback_app.cr'") + end + end + + it "corrects older marked agent instructions on rerun" do + SpecHelper.within_temp_directory do + Dir.mkdir_p("src") + File.write("shard.yml", "name: my_app\n") + File.write("src/my_app.cr", "puts :ok\n") + older_instructions = "# Keep this\n\n\nUse `crystal spec --affected` when available.\n\n" + File.write("CLAUDE.md", older_instructions) + File.write("AGENTS.md", older_instructions) + + command = AmberCLI::Commands::SetupAgentCommand.new("setup:agent") + command.execute + first_instructions = File.read("CLAUDE.md") + first_instructions.should contain("# Keep this") + first_instructions.should contain("Use `crystal-alpha spec --affected`") + first_instructions.should_not contain("Use `crystal spec --affected`") + File.read("AGENTS.md").should eq(first_instructions) + + command.execute + File.read("CLAUDE.md").should eq(first_instructions) + end + end + + it "treats the installed alpha without watch coordination as a build-only compiler" do + SpecHelper.within_temp_directory do |project| + Dir.mkdir_p("src") + File.write("shard.yml", "name: my_app\ntargets:\n my_app:\n main: src/my_app.cr\n") + File.write("src/my_app.cr", "puts :ok\n") + AmberCLI::Commands::SetupAgentCommand.new("setup:agent").execute + + tools = File.join(project, "fake-tools") + Dir.mkdir_p(tools) + compiler = File.join(tools, "crystal-alpha") + log = File.join(project, "calls.log") + File.write(compiler, "#!/bin/sh\nprintf 'compiler %s\\n' \"$*\" >> \"$TEST_COMMAND_LOG\"\ncase \"$1\" in\n watch) echo 'Usage: crystal watch [options] [programfile]'; exit 1 ;;\n build) exit 0 ;;\nesac\n") + File.chmod(compiler, 0o755) + hook = File.join(project, "bin/amber-agent-hook") + environment = {"PATH" => "#{tools}:/usr/bin:/bin", "TEST_COMMAND_LOG" => log} + + pre_output = IO::Memory.new + pre_errors = IO::Memory.new + pre_status = Process.run(hook, ["pre"], input: IO::Memory.new("{}"), output: pre_output, error: pre_errors, env: environment) + pre_status.exit_code.should eq(0) + pre_output.to_s.should_not contain("Usage:") + + stop_output = IO::Memory.new + stop_errors = IO::Memory.new + stop_status = Process.run(hook, ["stop"], input: IO::Memory.new("{}"), output: stop_output, error: stop_errors, env: environment) + stop_status.exit_code.should eq(0) + stop_errors.to_s.should_not contain("Usage:") + calls = File.read(log) + calls.scan(/compiler watch status/).size.should eq(2) + calls.should_not contain("compiler watch hold") + calls.should_not contain("compiler watch release") + calls.should_not contain("compiler watch build") + calls.should contain("compiler build --no-codegen src/my_app.cr") + end + end + + it "formats without an LSP and finds a project LSP when configured one is absent" do + SpecHelper.within_temp_directory do |project| + Dir.mkdir_p("src") + File.write("shard.yml", "name: my_app\ntargets:\n my_app:\n main: src/my_app.cr\n") + File.write("src/my_app.cr", "puts :ok\n") + AmberCLI::Commands::SetupAgentCommand.new("setup:agent").execute + File.write(".lsp.json", {"amber" => {"command" => "/missing/amber-lsp"}}.to_pretty_json + "\n") + + tools = File.join(project, "fake-tools") + Dir.mkdir_p(tools) + compiler = File.join(tools, "crystal-alpha") + log = File.join(project, "calls.log") + File.write(compiler, "#!/bin/sh\nprintf 'compiler %s\\n' \"$*\" >> \"$TEST_COMMAND_LOG\"\nif [ \"${TEST_FORMAT_EXIT:-0}\" -ne 0 ]; then\n echo 'format failed'\n exit \"$TEST_FORMAT_EXIT\"\nfi\n") + File.chmod(compiler, 0o755) + hook = File.join(project, "bin/amber-agent-hook") + payload = {"tool_input" => {"file_path" => "src/my_app.cr"}}.to_json + File.write("src/other.cr", "puts :other\n") + patch = "*** Begin Patch\n*** Update File: src/my_app.cr\n*** Update File: src/other.cr\n*** End Patch" + multiple_files_payload = {"tool_name" => "apply_patch", "tool_input" => {"command" => patch}}.to_json + environment = {"PATH" => "#{tools}:/usr/bin:/bin", "TEST_COMMAND_LOG" => log} + + errors = IO::Memory.new + missing_status = Process.run(hook, ["post"], input: IO::Memory.new(multiple_files_payload), output: IO::Memory.new, error: errors, env: environment) + missing_status.exit_code.should eq(0) + errors.to_s.scan(/amber-lsp unavailable/).size.should eq(1) + File.read(log).scan(/compiler tool format /).size.should eq(2) + + project_lsp = File.join(project, "bin/amber-lsp") + File.write(project_lsp, "#!/bin/sh\nprintf 'project-lsp %s\\n' \"$*\" >> \"$TEST_COMMAND_LOG\"\n") + File.chmod(project_lsp, 0o755) + errors = IO::Memory.new + project_status = Process.run(hook, ["post"], input: IO::Memory.new(payload), output: IO::Memory.new, error: errors, env: environment) + project_status.exit_code.should eq(0) + errors.to_s.should be_empty + File.read(log).should contain("project-lsp --check ") + + path_lsp = File.join(tools, "amber-lsp") + File.write(path_lsp, "#!/bin/sh\nprintf 'path-lsp %s\\n' \"$*\" >> \"$TEST_COMMAND_LOG\"\n") + File.chmod(path_lsp, 0o755) + File.write(".lsp.json", {"amber" => {"command" => "amber-lsp"}}.to_pretty_json + "\n") + project_calls_before = File.read(log).scan(/project-lsp --check /).size + project_priority = Process.run(hook, ["post"], input: IO::Memory.new(payload), output: IO::Memory.new, + error: IO::Memory.new, env: environment) + project_priority.exit_code.should eq(0) + File.read(log).scan(/project-lsp --check /).size.should eq(project_calls_before + 1) + File.read(log).should_not contain("path-lsp --check ") + + File.delete(project_lsp) + File.write(".lsp.json", {"amber" => {"command" => "/missing/amber-lsp"}}.to_pretty_json + "\n") + path_status = Process.run(hook, ["post"], input: IO::Memory.new(payload), output: IO::Memory.new, + error: IO::Memory.new, env: environment) + path_status.exit_code.should eq(0) + File.read(log).should contain("path-lsp --check ") + + configured_lsp = File.join(tools, "configured-lsp") + File.write(configured_lsp, "#!/bin/sh\nprintf 'configured-lsp %s\\n' \"$*\" >> \"$TEST_COMMAND_LOG\"\n") + File.chmod(configured_lsp, 0o755) + File.write(".lsp.json", {"amber" => {"command" => configured_lsp}}.to_pretty_json + "\n") + configured_status = Process.run(hook, ["post"], input: IO::Memory.new(payload), output: IO::Memory.new, + error: IO::Memory.new, env: environment) + configured_status.exit_code.should eq(0) + File.read(log).should contain("configured-lsp --check ") + + format_errors = IO::Memory.new + failed_format = Process.run(hook, ["post"], input: IO::Memory.new(payload), output: IO::Memory.new, + error: format_errors, env: environment.merge({"TEST_FORMAT_EXIT" => "1"})) + failed_format.exit_code.should eq(2) + format_errors.to_s.should contain("format failed") + end + end +end diff --git a/spec/commands/setup_lsp_spec.cr b/spec/commands/setup_lsp_spec.cr new file mode 100644 index 0000000..5190fd8 --- /dev/null +++ b/spec/commands/setup_lsp_spec.cr @@ -0,0 +1,17 @@ +require "../amber_cli_spec" +require "../../src/amber_cli/commands/setup_lsp" + +describe AmberCLI::Commands::SetupLSPCommand do + it "writes the existing LSP discovery configuration from an Amber project" do + SpecHelper.within_temp_directory do + AmberCLI::Commands::SetupLSPCommand.new("setup:lsp").execute + + config = File.read(".lsp.json") + config.should contain(%("amber")) + config.should contain(%("extensionToLanguage")) + config.should contain(%(".cr")) + File.file?(".claude-plugin/plugin.json").should be_true + File.file?(".amber-lsp.yml").should be_true + end + end +end diff --git a/src/amber_cli.cr b/src/amber_cli.cr index b886e52..14ff2cd 100644 --- a/src/amber_cli.cr +++ b/src/amber_cli.cr @@ -27,6 +27,7 @@ require "./amber_cli/commands/plugin" require "./amber_cli/commands/pipelines" require "./amber_cli/commands/generate" require "./amber_cli/commands/setup_lsp" +require "./amber_cli/commands/setup_agent" backend = Log::IOBackend.new backend.formatter = Log::Formatter.new do |entry, io| @@ -78,6 +79,7 @@ module AmberCLI plugin (pl) Generate application plugins pipelines Show application pipelines and plugs setup:lsp (lsp) Set up Amber LSP for Claude Code integration + setup:agent (agent) Set up Claude Code and Codex feedback hooks Options: --version, -v Show version number diff --git a/src/amber_cli/agent/resolve_compiler_for_agent_loop.cr b/src/amber_cli/agent/resolve_compiler_for_agent_loop.cr new file mode 100644 index 0000000..a9ec9a7 --- /dev/null +++ b/src/amber_cli/agent/resolve_compiler_for_agent_loop.cr @@ -0,0 +1,24 @@ +module AmberCLI::Agent + # Resolves the same compiler order used by generated agent hooks. + class ResolveCompilerForAgentLoop + getter? stock_compiler : Bool = false + + def initialize(@specified_path : String?, @path_lookup : Proc(String, String?)) + end + + def perform : String? + if specified_path = @specified_path + return specified_path unless specified_path.empty? + end + + ["crystal-alpha", "acrystal", "crystal"].each do |command| + if executable = @path_lookup.call(command) + @stock_compiler = command == "crystal" + return executable + end + end + + nil + end + end +end diff --git a/src/amber_cli/commands/new.cr b/src/amber_cli/commands/new.cr index e7e3a2e..04697c6 100644 --- a/src/amber_cli/commands/new.cr +++ b/src/amber_cli/commands/new.cr @@ -197,6 +197,7 @@ module AmberCLI::Commands info " amber generate scaffold Pet name:string:required species:string:required" info " amber database migrate" info " amber watch" + info " Tip: run amber setup:agent to install Claude Code and Codex hooks." info " # Choose -d pg or -d mysql when you need a server database." end diff --git a/src/amber_cli/commands/setup_agent.cr b/src/amber_cli/commands/setup_agent.cr new file mode 100644 index 0000000..96b0800 --- /dev/null +++ b/src/amber_cli/commands/setup_agent.cr @@ -0,0 +1,187 @@ +require "json" +require "yaml" +require "../core/base_command" +require "./setup_lsp" + +module AmberCLI::Agent + class HookHandler + include JSON::Serializable + include JSON::Serializable::Unmapped + + property type : String? = nil + property command : String? = nil + + def initialize(@type : String?, @command : String?) + end + end + + class HookGroup + include JSON::Serializable + include JSON::Serializable::Unmapped + + @[JSON::Field(emit_null: false)] + property matcher : String? = nil + property hooks : Array(HookHandler) = [] of HookHandler + + def initialize(@matcher : String?, @hooks : Array(HookHandler)) + end + end + + class HookEvents + include JSON::Serializable + include JSON::Serializable::Unmapped + + @[JSON::Field(key: "PreToolUse")] + property pre_tool_use : Array(HookGroup) = [] of HookGroup + @[JSON::Field(key: "PostToolUse")] + property post_tool_use : Array(HookGroup) = [] of HookGroup + @[JSON::Field(key: "Stop")] + property stop : Array(HookGroup) = [] of HookGroup + + def initialize + end + end + + class HookSettings + include JSON::Serializable + include JSON::Serializable::Unmapped + + property hooks : HookEvents = HookEvents.new + + def initialize + end + end + + # When an agent loop is installed, retain unrelated hook groups and settings. + class MergeAgentHooksIntoSettings + MATCHER = "Edit|Write|MultiEdit|NotebookEdit" + + def initialize(@existing_json : String) + end + + def perform : String + settings = @existing_json.empty? ? HookSettings.new : HookSettings.from_json(@existing_json) + add_hook(settings.hooks.pre_tool_use, MATCHER, "bin/amber-agent-hook pre") + add_hook(settings.hooks.post_tool_use, MATCHER, "bin/amber-agent-hook post") + add_hook(settings.hooks.stop, nil, "bin/amber-agent-hook stop") + settings.to_pretty_json + "\n" + end + + private def add_hook(groups : Array(HookGroup), matcher : String?, command : String) : Nil + group = groups.find { |candidate| candidate.matcher == matcher } + unless group + group = HookGroup.new(matcher, [] of HookHandler) + groups << group + end + return if group.hooks.any? { |handler| handler.type == "command" && handler.command == command } + + group.hooks << HookHandler.new("command", command) + end + end +end + +module AmberCLI::Commands + # Installs the optional Claude Code and Codex feedback loop into an Amber V2 app. + class SetupAgentCommand < AmberCLI::Core::BaseCommand + AGENT_HOOK_SCRIPT = {{ read_file("#{__DIR__}/../templates/agent/amber-agent-hook") }} + DOCUMENT_START = "" + DOCUMENT_END = "" + + def help_description : String + "Set up the Claude Code and Codex agent loop in an Amber V2 project" + end + + def setup_command_options + end + + def execute + main_file = find_project_main_file + SetupLSPCommand.new("setup:lsp").execute + [".claude/settings.json", ".codex/hooks.json"].each do |path| + write_merged_hooks(path) + end + write_hook_script(main_file) + ["CLAUDE.md", "AGENTS.md"].each do |path| + append_agent_loop_instructions(path) + end + success "Amber agent loop installed." + end + + private def find_project_main_file : String + unless File.file?("shard.yml") + raise "Run amber setup:agent from an Amber project with shard.yml" + end + + manifest = YAML.parse(File.read("shard.yml")) + targets = manifest["targets"]?.try(&.as_h?) + first_target = targets.try(&.values.first?) + target_main = first_target.try(&.["main"]?) + main_file = target_main.try(&.as_s?) + + unless first_target + project_name = manifest["name"]?.try(&.as_s?) + main_file = "src/#{project_name}.cr" if project_name + end + + unless main_file && main_file.matches?(/\A(?:src\/)?[A-Za-z0-9_\/.-]+\.cr\z/) && !main_file.includes?("..") && File.file?(main_file) + raise "shard.yml must declare an existing targets..main or src/.cr Crystal file" + end + main_file + end + + private def write_merged_hooks(path : String) : Nil + existing_json = File.file?(path) ? File.read(path) : "" + merged_json = AmberCLI::Agent::MergeAgentHooksIntoSettings.new(existing_json).perform + return if existing_json == merged_json + + Dir.mkdir_p(File.dirname(path)) + File.write(path, merged_json) + info "Updated: #{path}" + end + + private def write_hook_script(main_file : String) : Nil + path = "bin/amber-agent-hook" + content = AGENT_HOOK_SCRIPT.sub("__AMBER_MAIN__", main_file) + Dir.mkdir_p("bin") + if !File.file?(path) || File.read(path) != content + File.write(path, content) + info "Updated: #{path}" + end + File.chmod(path, 0o755) + end + + private def append_agent_loop_instructions(path : String) : Nil + content = File.file?(path) ? File.read(path) : "" + if content.includes?(DOCUMENT_START) && content.includes?(DOCUMENT_END) + marker_start = content.index(DOCUMENT_START) + marker_end = content.index(DOCUMENT_END) + if marker_start && marker_end && marker_start < marker_end + section = content[marker_start...marker_end] + updated_section = section.sub("Use `crystal spec --affected`", "Use `crystal-alpha spec --affected`") + if updated_section != section + File.write(path, content.sub(section, updated_section)) + info "Updated: #{path}" + end + return + end + end + raise "Incomplete Amber agent loop marker in #{path}" if content.includes?(DOCUMENT_START) || content.includes?(DOCUMENT_END) + + section = <<-MARKDOWN + #{DOCUMENT_START} + ## Agent loop + + Run `crystal-alpha watch build` after edits. Do not start another watcher. + Use `crystal-alpha spec --affected` when available. Format Crystal files with + `crystal-alpha tool format`. The installed hooks hold the watcher during + edits, check each changed file, and build when the agent stops. + #{DOCUMENT_END} + MARKDOWN + prefix = content.empty? ? "" : content.rstrip + "\n\n" + File.write(path, prefix + section) + info "Updated: #{path}" + end + end +end + +AmberCLI::Core::CommandRegistry.register("setup:agent", ["agent"], AmberCLI::Commands::SetupAgentCommand) diff --git a/src/amber_cli/commands/setup_lsp.cr b/src/amber_cli/commands/setup_lsp.cr index 94adcb1..77bcccc 100644 --- a/src/amber_cli/commands/setup_lsp.cr +++ b/src/amber_cli/commands/setup_lsp.cr @@ -1,4 +1,5 @@ require "../core/base_command" +require "../agent/resolve_compiler_for_agent_loop" # The `setup:lsp` command configures the Amber LSP server for Claude Code # integration in an Amber project directory. @@ -126,12 +127,6 @@ module AmberCLI::Commands return Dir.current end - # Check the known development location - dev_path = File.expand_path("~/open_source_coding_projects/amber_cli") - if File.exists?(File.join(dev_path, "src", "amber_lsp.cr")) - return dev_path - end - nil end @@ -145,8 +140,18 @@ module AmberCLI::Commands Dir.mkdir_p(File.join(cli_project_root, "bin")) unless Dir.exists?(File.join(cli_project_root, "bin")) + resolver = AmberCLI::Agent::ResolveCompilerForAgentLoop.new( + ENV["CRYSTAL_ALPHA"]?, ->(command : String) { Process.find_executable(command) } + ) + compiler = resolver.perform + unless compiler + error "No Crystal compiler found (CRYSTAL_ALPHA, crystal-alpha, acrystal, crystal)" + exit(1) + end + warning "Fast rebuilds need crystal-alpha; using stock crystal." if resolver.stock_compiler? + process = Process.run( - "crystal", + compiler, ["build", source_path, "-o", binary_path, "--release"], output: Process::Redirect::Inherit, error: Process::Redirect::Inherit diff --git a/src/amber_cli/templates/agent/amber-agent-hook b/src/amber_cli/templates/agent/amber-agent-hook new file mode 100644 index 0000000..7172f13 --- /dev/null +++ b/src/amber_cli/templates/agent/amber-agent-hook @@ -0,0 +1,224 @@ +#!/bin/sh +set -u + +project_root=$(CDPATH='' cd "$(dirname "$0")/.." && pwd -P) || exit 2 +cd "$project_root" || exit 2 + +compiler_is_stock=0 +if [ -n "${CRYSTAL_ALPHA:-}" ]; then + compiler=$CRYSTAL_ALPHA +elif command -v crystal-alpha >/dev/null 2>&1; then + compiler=crystal-alpha +elif command -v acrystal >/dev/null 2>&1; then + compiler=acrystal +elif command -v crystal >/dev/null 2>&1; then + compiler=crystal + compiler_is_stock=1 + echo 'amber-agent-hook: fast rebuilds need crystal-alpha; using stock crystal' >&2 +else + echo 'amber-agent-hook: no Crystal compiler found' >&2 + exit 2 +fi + +watch_coordination_is_available() { + [ "$compiler_is_stock" -eq 0 ] || return 1 + "$compiler" watch status >/dev/null 2>&1 + watch_status=$? + [ "$watch_status" -eq 0 ] || [ "$watch_status" -eq 2 ] +} + +find_lsp() { + configured_lsp='' + if [ -f .lsp.json ]; then + configured_lsp=$(awk ' + function string_value( character, escaped, result) { + result = ""; escaped = 0 + while (position <= length(document)) { + character = substr(document, position++, 1) + if (escaped) { + if (character == "u") return "" + result = result character + escaped = 0; continue + } + if (character == "\\") { escaped = 1; continue } + if (character == "\"") return result + result = result character + } + return "" + } + { document = document $0 "\n" } + END { + position = 1 + while (position <= length(document)) { + if (substr(document, position++, 1) != "\"") continue + key = string_value() + while (substr(document, position, 1) ~ /[[:space:]]/) position++ + if (substr(document, position++, 1) != ":") continue + while (substr(document, position, 1) ~ /[[:space:]]/) position++ + if (substr(document, position++, 1) != "\"") continue + value = string_value() + if (key == "command") { print value; exit } + } + } + ' .lsp.json) + fi + + if [ -n "$configured_lsp" ]; then + if [ -x "$configured_lsp" ]; then + printf '%s\n' "$configured_lsp" + return 0 + fi + case "$configured_lsp" in + amber-lsp) ;; + *) + if command -v "$configured_lsp" >/dev/null 2>&1; then + printf '%s\n' "$configured_lsp" + return 0 + fi + ;; + esac + fi + if [ -x bin/amber-lsp ]; then + printf '%s\n' bin/amber-lsp + return 0 + fi + if command -v amber-lsp >/dev/null 2>&1; then + printf '%s\n' amber-lsp + return 0 + fi + return 1 +} + +case "${1:-}" in + pre) + if watch_coordination_is_available; then + "$compiler" watch hold + status=$? + [ "$status" -eq 2 ] && exit 0 + exit "$status" + fi + ;; + post) + # Claude supplies file_path; Codex supplies an apply_patch command. + # Parse JSON strings and patch headers without another runtime. + edited_files=$(awk ' + function string_value( character, escaped, result) { + result = ""; escaped = 0 + while (position <= length(payload)) { + character = substr(payload, position++, 1) + if (escaped) { + if (character == "n") result = result "\n" + else if (character == "r") result = result "\r" + else if (character == "t") result = result "\t" + else if (character == "u") return "" + else result = result character + escaped = 0; continue + } + if (character == "\\") { escaped = 1; continue } + if (character == "\"") return result + result = result character + } + return "" + } + { payload = payload $0 "\n" } + END { + position = 1 + while (position <= length(payload)) { + if (substr(payload, position++, 1) != "\"") continue + key = string_value() + while (substr(payload, position, 1) ~ /[[:space:]]/) position++ + if (substr(payload, position++, 1) != ":") continue + while (substr(payload, position, 1) ~ /[[:space:]]/) position++ + if (substr(payload, position++, 1) != "\"") continue + value = string_value() + if (key == "file_path") direct_path = value + else if (key == "path" && direct_path == "") direct_path = value + else if (key == "tool_name") tool_name = value + else if (key == "command") patch = value + } + if (tool_name == "apply_patch" && index(patch, "*** Begin Patch") > 0) { + count = split(patch, lines, "\n") + for (line_number = 1; line_number <= count; line_number++) { + line = lines[line_number] + if (line ~ /^\*\*\* (Update|Add) File: /) { + sub(/^\*\*\* (Update|Add) File: /, "", line) + sub(/\r$/, "", line) + if (!seen[line]++) print line + } + } + } else if (direct_path != "") { + print direct_path + } + } + ') + [ -n "$edited_files" ] || exit 0 + lsp=$(find_lsp) || lsp='' + printf '%s\n' "$edited_files" | while IFS= read -r edited_file; do + case "$edited_file" in + *.cr) ;; + *) continue ;; + esac + case "$edited_file" in + /*) ;; + *) edited_file=$project_root/$edited_file ;; + esac + [ -f "$edited_file" ] || continue + [ -L "$edited_file" ] && continue + edited_directory=$(CDPATH='' cd "$(dirname "$edited_file")" && pwd -P) || continue + case "$edited_directory" in + "$project_root"|"$project_root"/*) ;; + *) continue ;; + esac + format_output=$("$compiler" tool format "$edited_file" 2>&1) + format_status=$? + if [ "$format_status" -ne 0 ]; then + [ -z "$format_output" ] || printf '%s\n' "$format_output" >&2 + exit 2 + fi + [ -z "$format_output" ] || printf '%s\n' "$format_output" + if [ -n "$lsp" ]; then + diagnostics=$("$lsp" --check "$edited_file" 2>&1) + diagnostic_status=$? + if [ "$diagnostic_status" -ne 0 ]; then + [ -z "$diagnostics" ] || printf '%s\n' "$diagnostics" >&2 + exit 2 + fi + [ -z "$diagnostics" ] || printf '%s\n' "$diagnostics" + else + if [ "${warned_missing_lsp:-0}" -eq 0 ]; then + echo 'amber-agent-hook: amber-lsp unavailable; skipping file diagnostics' >&2 + warned_missing_lsp=1 + fi + fi + done + exit $? + ;; + stop) + if watch_coordination_is_available; then + "$compiler" watch release >/dev/null 2>&1 || : + output=$("$compiler" watch build 2>&1) + status=$? + if [ "$status" -eq 0 ]; then + [ -z "$output" ] || printf '%s\n' "$output" + exit 0 + fi + if [ "$status" -ne 2 ]; then + [ -z "$output" ] || printf '%s\n' "$output" >&2 + exit 2 + fi + elif [ "$compiler_is_stock" -eq 0 ]; then + echo 'amber-agent-hook: watch coordination unavailable; using direct build' >&2 + fi + output=$("$compiler" build --no-codegen '__AMBER_MAIN__' 2>&1) + status=$? + if [ "$status" -ne 0 ]; then + [ -z "$output" ] || printf '%s\n' "$output" >&2 + exit 2 + fi + [ -z "$output" ] || printf '%s\n' "$output" + ;; + *) + echo 'Usage: bin/amber-agent-hook pre|post|stop' >&2 + exit 2 + ;; +esac diff --git a/src/amber_lsp.cr b/src/amber_lsp.cr index 472f055..1fa26ae 100644 --- a/src/amber_lsp.cr +++ b/src/amber_lsp.cr @@ -24,5 +24,15 @@ require "./amber_lsp/configuration" require "./amber_lsp/analyzer" require "./amber_lsp/controller" require "./amber_lsp/server" +require "./amber_lsp/check_file_for_diagnostics" -AmberLSP::Server.new(STDIN, STDOUT).run +if ARGV[0]? == "--check" + file_path = ARGV[1]? + unless file_path + STDERR.puts "Usage: amber-lsp --check FILE.cr" + exit(2) + end + exit(AmberLSP::CheckFileForDiagnostics.new(file_path).perform) +else + AmberLSP::Server.new(STDIN, STDOUT).run +end diff --git a/src/amber_lsp/check_file_for_diagnostics.cr b/src/amber_lsp/check_file_for_diagnostics.cr new file mode 100644 index 0000000..c962fd0 --- /dev/null +++ b/src/amber_lsp/check_file_for_diagnostics.cr @@ -0,0 +1,26 @@ +module AmberLSP + class CheckFileForDiagnostics + def initialize(@file_path : String, @output : IO = STDOUT, @error : IO = STDERR) + end + + def perform : Int32 + path = File.expand_path(@file_path) + unless File.file?(path) + @error.puts "amber-lsp: file not found: #{path}" + return 2 + end + + analyzer = Analyzer.new + analyzer.configure(ProjectContext.detect(Dir.current)) + diagnostics = analyzer.analyze(path, File.read(path)) + diagnostics.each do |diagnostic| + line = diagnostic.range.start.line + 1 + @output.puts "#{path}:#{line}: #{diagnostic.severity.to_s.downcase}: #{diagnostic.code}: #{diagnostic.message}" + end + diagnostics.any? { |diagnostic| diagnostic.severity == Rules::Severity::Error } ? 1 : 0 + rescue ex : File::Error | YAML::ParseException + @error.puts "amber-lsp: #{ex.message}" + 2 + end + end +end