Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions spec/amber_lsp/check_file_for_diagnostics_spec.cr
Original file line number Diff line number Diff line change
@@ -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
327 changes: 327 additions & 0 deletions spec/commands/setup_agent_spec.cr

Large diffs are not rendered by default.

17 changes: 17 additions & 0 deletions spec/commands/setup_lsp_spec.cr
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions src/amber_cli.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions src/amber_cli/agent/resolve_compiler_for_agent_loop.cr
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions src/amber_cli/commands/new.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
187 changes: 187 additions & 0 deletions src/amber_cli/commands/setup_agent.cr
Original file line number Diff line number Diff line change
@@ -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 = "<!-- amber-agent-loop:start -->"
DOCUMENT_END = "<!-- amber-agent-loop: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.<name>.main or src/<name>.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)
19 changes: 12 additions & 7 deletions src/amber_cli/commands/setup_lsp.cr
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
Loading
Loading