Repository navigation
feat(devel): /init command — AI-powered Enkan project generator - #170
Conversation
…de Code CLI Adds an interactive /init REPL command that prompts the user for requirements and invokes claude -p to generate a complete Enkan project with controllers, SystemFactory, ApplicationFactory, DevMain, and pom.xml. - Add NEED_INPUT status to ReplResponse.ResponseStatus for multi-turn prompts - Add Transport.sendPrompt() default method for prompting user input - Add InitCommand: interactive requirements gathering + claude -p subprocess - Register InitCommand in DevelCommandRegister as a local command - Handle NEED_INPUT in enkan-repl.js so browser REPL accepts mid-command input Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…t to enkan-repl CLI InitCommand now lives in enkan-repl so the enkan-repl CLI client can invoke /init without a REPL server connection. - Move InitCommand: enkan-devel → enkan-repl (enkan.system.repl.command) - Add JLineTransport: Transport impl backed by JLine LineReader for client-local I/O - ReplClient: register InitCommand as a client-local command; dispatch /init (and any future client-local commands) before checking socket state - enkan-devel: add enkan-repl dependency; update DevelCommandRegister import Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously claude -p with Write tool produced no stdout until completion, making /init appear frozen. Switch to stream-json output and parse assistant text deltas and tool_use names to forward progress to the transport as it arrives. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…clude-partial-messages --output-format stream-json emits text as stream_event/content_block_delta/text_delta events, not as assistant-level content. Add --include-partial-messages and --verbose flags, and update the parser to extract text from text_delta and tool names from tool_use content_block_start events. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ument ProcessBuilder sets up a pipe for stdin, so claude -p was waiting for stdin input and never receiving the prompt that was passed as a CLI argument. Write the prompt to the process stdin stream and close it immediately. Verified locally: stream_event/text_delta lines arrive incrementally and text is correctly extracted by the parser. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… API Switch /init command from spawning claude -p subprocess to calling any OpenAI-compatible REST API (Anthropic, OpenAI, LM Studio, Ollama) via java.net.http.HttpClient with SSE streaming. - Force HTTP/1.1 to ensure compatibility with local inference servers - Fall back to reasoning_content when thinking models omit content field - Parse ### path/file + fenced code block format from response - Write each generated file directly to the output directory - Configure via ENKAN_AI_API_URL / ENKAN_AI_API_KEY / ENKAN_AI_MODEL (or system properties enkan.ai.apiUrl / apiKey / model) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…UI, compile-fix loop, and auto-connect
- Move InitCommand from enkan-repl to enkan-repl-client so it runs client-locally
- Add Transport.startSpinner/stopSpinner/requestConnect default methods
- JLineTransport: spinner animation, sendPrompt stores text for readLine, connect callback
- ReplClient: ANSI colored prompt (green=connected, yellow=disconnected), printErr/printInfo helpers
- InitCommand: fetch reference files from GitHub raw URLs at runtime
- InitCommand: spinner ("Thinking...") during LLM calls; show only filename during file writing
- InitCommand: write fixed templates (DevMain, pom.xml with dev profile, SystemFactory skeleton)
- InitCommand: compileAndFix loop — compile with mvn, send errors to LLM, apply fixes (max 3 attempts)
- InitCommand: launchAndConnect — optionally start app via mvn, poll port file, call transport.requestConnect
- InitCommand: toJsonString uses switch loop to avoid double-escape bugs
- InitCommand: path traversal prevention via normalize().startsWith()
- InitCommand: containsForbiddenFramework check to reject Spring Boot generation
- Add InitCommandTest covering plan review, cancellation, markdown rendering, forbidden framework detection
- DevelCommandRegister: register /init as local command
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Share a single HttpClient instance across all HTTP calls (avoid per-call allocation) - Cache reference file fetches once per execute(); planning and generation phases now share the same in-memory copies instead of making 8 separate GitHub requests - Read Enkan parent version dynamically from the jar manifest (Implementation-Version) so generated pom.xml stays correct after releases; falls back to the current snapshot when running outside a packaged jar - Replace unescapeJson() replace-chain with a character-by-character loop (symmetric with toJsonString; eliminates fragile ordering dependency) - Capture the Process handle returned by launchAndConnect() and log its PID Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove stale enkan-repl dependency from enkan-devel/pom.xml; InitCommand moved to enkan-repl-client so this dependency was no longer valid - Default referenceCache to Collections.emptyMap() to prevent NPE when reviewPlanInteractively/generateWithApi are called outside execute() - Add getOrFetchReferenceCache() lazy accessor so all callers are safe regardless of call order - Fix findJsonStringEnd return-value check: use end < 0 || end == start instead of end <= start for clarity (end == -1 is unambiguous) - Remove socket == null guard from /help dispatch so the help text is shown even when connected to a server - JLineTransport implements AutoCloseable; close() stops spinner and shuts down the scheduler thread; ReplClient wraps JLineTransport in try-with-resources Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove silent catch(Exception) around local command execution; JLineTransport.close() does not throw so the empty catch block was pure dead code that would hide future bugs - Drop eager referenceCache load from execute() so getOrFetchReferenceCache() becomes the single source of truth (eliminates the dual init paths flagged in review) - Change enkanVersion() fallback from "0.14.2-SNAPSHOT" to "UNKNOWN" so a failure to read the jar manifest surfaces clearly in generated pom.xml instead of silently pinning an outdated version Test coverage additions (8 → 17 tests): - enkanVersion fallback - inferProjectName stop-word handling, length cap, numbers/symbols, empty input - isApprovalInput / isCancelInput English + Japanese + whitespace trimming - renderMarkdownForTerminal null/blank handling - containsForbiddenFramework null/blank and case insensitivity Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Move enkanVersion() next to other helper methods (was awkwardly sandwiched between constant declarations) - Add referencesFetched flag so getOrFetchReferenceCache() attempts the network fetch exactly once per instance; offline runs no longer re-hit GitHub on every prompt - Catch PromptAbortedException separately from generateWithApi() so user aborts inside launchAndConnect() show "Init cancelled." instead of "Generation failed: null" - Use safeMessage() for the generic Exception catch so null messages never leak into the error output - CapturingTransport overrides sendPrompt() and records prompts separately; prompt assertions now check promptLines (clearer test intent) - Promote extractFilePath, isFixedTemplateFile, unescapeJson, extractJsonStringField, and writeGeneratedFiles to package-private for targeted testing Test coverage additions (17 → 25 tests): - extractFilePath markdown header formats and non-path rejection - isFixedTemplateFile recognition of pom.xml/DevMain/Main/*SystemFactory, including Windows-style backslash paths - unescapeJson standard escapes, trailing lone backslash, unknown escapes, empty - extractJsonStringField content/reasoning_content excludeKey handling - writeGeneratedFiles happy path, path traversal rejection, fixed-template skipping Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…se duplicate handlers
- Fetch the 4 reference URLs in parallel via HttpClient.sendAsync; worst-case
wall time drops from ~40s to ~10s on slow networks
- Replace referenceCache + referencesFetched pair with a null sentinel; one
field is enough and the semantics are clearer (null = not yet attempted)
- Hoist outPath.normalize() out of the writeGeneratedFiles loop — it was
recomputed on every file for the startsWith check
- Initialize the SSE response StringBuilder with 32K capacity to reduce
reallocation for large generation outputs
- Collapse execute()'s two try blocks into one; PromptAbortedException now
has a single handler rather than duplicated branches
- Drop narrate-what comments ("// DevMain.java", "// Write fixed templates
first", "// Check if this match is actually inside excludeKey"); the
surrounding identifiers already say what the code does
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Resolve apiUrl/apiKey/model once at execute() entry and store them as instance fields; drop the duplicate config() reads from reviewPlanInteractively and generateWithApi - Shrink signatures: requestChatCompletion, generatePlanWithApi, revisePlanWithApi, and compileAndFix no longer take apiUrl/apiKey/model (they read the fields) - Wrap CompletableFuture.join() in fetchAllReferences() with try/catch for CompletionException/CancellationException so a single misbehaving future cannot surface as "Generation failed: ..." from the outer handler - Update InitCommandTest overrides to match the new signatures Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds an AI-assisted /init workflow to generate Enkan projects from the REPL, and extends the REPL protocol/client UX to support multi-turn prompting.
Changes:
- Extend REPL protocol with a new
NEED_INPUTstatus andTransport.sendPrompt()for interactive prompts. - Add a new client-local
/initcommand (plus JLine-backed transport) to gather requirements, call an LLM, write a project scaffold, and optionally compile/fix/run. - Update both CLI and browser REPL clients to better support local commands/help and interactive input flow.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| enkan-system/src/main/java/enkan/system/Transport.java | Adds sendPrompt() and no-op hooks (spinner/connect) for richer client UX. |
| enkan-system/src/main/java/enkan/system/ReplResponse.java | Adds NEED_INPUT response status flag. |
| enkan-repl-client/src/main/java/enkan/system/repl/command/InitCommand.java | Implements interactive AI-backed project generation and file writing. |
| enkan-repl-client/src/main/java/enkan/system/repl/client/ReplClient.java | Adds local command help/completion and wiring for /init; adjusts output rendering. |
| enkan-repl-client/src/main/java/enkan/system/repl/client/JLineTransport.java | New Transport implementation for client-local commands in the CLI. |
| enkan-repl-client/src/test/java/enkan/system/repl/command/InitCommandTest.java | Adds unit tests for init prompt flow, markdown rendering, parsing/writing safeguards. |
| enkan-repl-client/src/test/java/enkan/system/repl/client/ReplClientLocalHelpTest.java | Tests local help and local command list includes /init. |
| enkan-repl-client/pom.xml | Adds runtime slf4j-simple dependency for CLI logging. |
| enkan-devel/src/main/resources/enkan/repl/enkan-repl.js | Handles NEED_INPUT by unlocking terminal input between prompt turns. |
| enkan-devel/src/main/java/enkan/system/devel/DevelCommandRegister.java | Optionally registers /init via reflection when available on the classpath. |
Comments suppressed due to low confidence (1)
enkan-repl-client/src/main/java/enkan/system/repl/client/ReplClient.java:171
NEED_INPUTresponses are not forwarded to the main loop viapipe, so after a command is sent the client can block indefinitely waiting for a DONE signal and never return toreadLine()to collect the requested input. The renderer should signal NEED_INPUT (similar to DONE) and the main loop should resume input collection accordingly.
reader.getTerminal().writer().flush();
pipe.send("shutdown");
break;
} else if (res.getStatus().contains(DONE)) {
pipe.send("done");
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| ReplResponse res = fressian.read(msg.pop().getData(), ReplResponse.class); | ||
| if (res.getOut() != null) { | ||
| reader.getTerminal().writer().println(res.getOut()); | ||
| reader.getTerminal().writer().print(res.getOut()); |
There was a problem hiding this comment.
In the renderer thread, res.getOut() is now written with print() instead of println(). Most server responses (e.g., JShell output lines) are sent without trailing newlines, so this change will concatenate lines and can also cause the next prompt to appear on the same line. Consider restoring newline behavior for non-prompt output (or only using print() when NEED_INPUT is present).
| reader.getTerminal().writer().print(res.getOut()); | |
| if (res.getStatus().contains(ReplResponse.ResponseStatus.NEED_INPUT)) { | |
| reader.getTerminal().writer().print(res.getOut()); | |
| } else { | |
| reader.getTerminal().writer().println(res.getOut()); | |
| } |
| if (msg.status && msg.status.indexOf('DONE') !== -1) { | ||
| showPrompt(); | ||
| } else if (msg.status && msg.status.indexOf('NEED_INPUT') !== -1) { | ||
| waitingForResponse = false; |
There was a problem hiding this comment.
On NEED_INPUT the code only flips waitingForResponse = false, but does not re-render a prompt or manage cursor placement. If the intention is for msg.out to be the prompt, make sure the terminal output path treats NEED_INPUT as a prompt (no auto-newline) and leaves the cursor ready for typing.
| private static final String DEFAULT_API_URL = "https://api.anthropic.com/v1"; | ||
| private static final String DEFAULT_MODEL = "claude-sonnet-4-5"; | ||
| private static final Pattern HEADING_PATTERN = Pattern.compile("^\\s{0,3}#{1,6}\\s+"); | ||
| private static final int MAX_GENERATION_ATTEMPTS = 2; |
There was a problem hiding this comment.
Default configuration claims Anthropic compatibility, but the client always calls POST {apiUrl}/chat/completions with an OpenAI-style payload and a default model name (claude-sonnet-4-5). With the current defaults (https://api.anthropic.com/v1), the resulting endpoint (/v1/chat/completions) is not a valid Anthropic API path, so /init will fail unless users override ENKAN_AI_API_URL. Either change defaults to an actually OpenAI-compatible base URL, or implement provider-specific endpoints/payloads (e.g. Anthropic /v1/messages).
| String requestChatCompletion(String systemPrompt, String userPrompt, String spinnerLabel, Transport transport) | ||
| throws IOException, InterruptedException { | ||
| if (transport != null) transport.startSpinner(spinnerLabel); | ||
| try { | ||
| String requestBody = buildRequestBody(model, systemPrompt, userPrompt, true); | ||
| HttpRequest request = HttpRequest.newBuilder() | ||
| .uri(URI.create(apiUrl + "/chat/completions")) | ||
| .version(HttpClient.Version.HTTP_1_1) | ||
| .timeout(Duration.ofMinutes(10)) | ||
| .header("Content-Type", "application/json") |
There was a problem hiding this comment.
requestChatCompletion() hardcodes the OpenAI streaming endpoint suffix "/chat/completions". If you intend to support multiple providers, make the path configurable or derive it from the provider type; otherwise the DEFAULT_API_URL needs to match this contract. Also consider setting appropriate streaming headers (e.g., Accept: text/event-stream) if required by the backend.
| String requestChatCompletion(String systemPrompt, String userPrompt, String spinnerLabel, Transport transport) | |
| throws IOException, InterruptedException { | |
| if (transport != null) transport.startSpinner(spinnerLabel); | |
| try { | |
| String requestBody = buildRequestBody(model, systemPrompt, userPrompt, true); | |
| HttpRequest request = HttpRequest.newBuilder() | |
| .uri(URI.create(apiUrl + "/chat/completions")) | |
| .version(HttpClient.Version.HTTP_1_1) | |
| .timeout(Duration.ofMinutes(10)) | |
| .header("Content-Type", "application/json") | |
| private URI resolveChatCompletionUri() { | |
| String normalizedApiUrl = apiUrl.endsWith("/") ? apiUrl.substring(0, apiUrl.length() - 1) : apiUrl; | |
| if (normalizedApiUrl.endsWith("/chat/completions") || normalizedApiUrl.endsWith("/messages")) { | |
| return URI.create(normalizedApiUrl); | |
| } | |
| return URI.create(normalizedApiUrl + "/chat/completions"); | |
| } | |
| String requestChatCompletion(String systemPrompt, String userPrompt, String spinnerLabel, Transport transport) | |
| throws IOException, InterruptedException { | |
| if (transport != null) transport.startSpinner(spinnerLabel); | |
| try { | |
| String requestBody = buildRequestBody(model, systemPrompt, userPrompt, true); | |
| HttpRequest request = HttpRequest.newBuilder() | |
| .uri(resolveChatCompletionUri()) | |
| .version(HttpClient.Version.HTTP_1_1) | |
| .timeout(Duration.ofMinutes(10)) | |
| .header("Content-Type", "application/json") | |
| .header("Accept", "text/event-stream") |
| String path = extractFilePath(line); | ||
| if (path != null) { | ||
| currentFile = isFixedTemplateFile(path) ? null : path; | ||
| if (currentFile != null) { | ||
| transport.sendOut(GREEN + " ✓" + RESET + " " + currentFile + "\n"); | ||
| } | ||
| } else if (currentFile != null && (line.startsWith("```"))) { | ||
| inBlock = true; | ||
| codeLines.setLength(0); |
There was a problem hiding this comment.
writeGeneratedFiles() skips fixed-template files by setting currentFile = null, but then it does not enter inBlock when the fenced code block starts. That means the skipped file’s code lines are still parsed as normal text and could accidentally be interpreted as new file headers (e.g., a code line starting with ###). Track/consume fenced blocks even when the current file is being skipped so the parser stays aligned and cannot be confused by content inside skipped blocks.
| /** | ||
| * REPL command that interactively generates a new Enkan application project | ||
| * using an OpenAI-compatible chat completion API (Anthropic, OpenAI, LM Studio, Ollama, etc.). | ||
| * | ||
| * <p>Usage: {@code /init} | ||
| * | ||
| * <p>Configuration via environment variables or system properties: | ||
| * <ul> | ||
| * <li>{@code ENKAN_AI_API_URL} / {@code enkan.ai.apiUrl} — base URL (default: {@code https://api.anthropic.com/v1})</li> | ||
| * <li>{@code ENKAN_AI_API_KEY} / {@code enkan.ai.apiKey} — API key</li> | ||
| * <li>{@code ENKAN_AI_MODEL} / {@code enkan.ai.model} — model name (default: {@code claude-sonnet-4-5})</li> | ||
| * </ul> | ||
| * | ||
| * <p>This command is a client-local command: it can be run without a REPL server | ||
| * connection, making it usable directly from the {@code enkan-repl} CLI client. | ||
| * |
There was a problem hiding this comment.
PR description says /init spawns claude -p, but InitCommand is implemented as an HTTP client for an OpenAI-compatible /chat/completions API. Either update the implementation to match the documented CLI-based approach, or update the PR description / command docs so users configure the correct backend and understand the dependency (HTTP API vs local claude).
| } | ||
| if (msg.status && msg.status.indexOf('DONE') !== -1) { | ||
| showPrompt(); | ||
| } else if (msg.status && msg.status.indexOf('NEED_INPUT') !== -1) { | ||
| waitingForResponse = false; | ||
| } |
There was a problem hiding this comment.
For NEED_INPUT, input is unlocked but msg.out is still rendered with term.writeln(msg.out) above, which always appends a newline. If msg.out is intended to be an inline prompt (e.g. ending with ": "), consider rendering output differently when NEED_INPUT is present (e.g. term.write without newline) so the user can type on the same line as the prompt.
- ReplClient renderer: restore println() for non-prompt output; only use print()
when NEED_INPUT is set, so multi-line JShell output no longer concatenates
- ReplClient renderer: forward NEED_INPUT to the main loop via pipe.send("need-input")
so the client no longer blocks waiting for DONE before returning to readLine()
- enkan-repl.js: render NEED_INPUT messages with term.write (no newline) so the
prompt keeps the cursor on the same line in the browser terminal
- InitCommand.writeGeneratedFiles: track a separate skipBlock flag so fenced
code blocks under skipped fixed-template headers are still consumed;
prevents code lines starting with "### " or "**" from being misread as new
file headers
- InitCommand.resolveChatCompletionUri(): normalize the configured API URL —
accept both base URLs (/v1) and full paths (/v1/chat/completions), tolerate
a trailing slash
- InitCommand.requestChatCompletion: add Accept: text/event-stream header
- InitCommand: package-private apiUrl/apiKey/model fields for testability
- InitCommand class Javadoc: clarify that defaults target Anthropic's
OpenAI-compatible endpoint, and document URL normalization
- Tests: add resolveChatCompletionUri URL normalization cases and a regression
test for writeGeneratedFiles consuming fenced blocks under skipped headers
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Summary
/initREPL command that interactively collects project requirements and calls an OpenAI-compatible chat completion API (OpenAI, Anthropic's OpenAI-compat endpoint, LM Studio, Ollama, etc.) to generate a complete Enkan applicationNEED_INPUTstatus for multi-turn promptsmvn compileis run in a fix-loop (max 3 attempts), then the app can be auto-started and the REPL client auto-connectedenkan-repl.js) and CLI client soNEED_INPUTprompts render on the same line as the cursorChanges
Protocol extension (
enkan-system)ReplResponse.ResponseStatus: addedNEED_INPUTenum valueTransport: added default methodssendPrompt(String),startSpinner(String),stopSpinner(),requestConnect(int)— all no-ops by default so existing transports are unaffectedNew command (
enkan-repl-client)InitCommand(client-local command): collects description/name/groupId/outputDir via interactive prompts, generates and reviews an implementation plan with the LLM, then writes fixed templates (DevMain.java,pom.xml,SystemFactoryskeleton) + AI-generated files, compiles with a fix-loop, and optionally launches the app + auto-connectsJLineTransport: Transport backed by a JLineLineReaderfor running client-local commands (spinner animation, prompt handling, connect callback,AutoCloseablefor scheduler shutdown)ReplClient: registers/initas a client-local command, adds ANSI-colored prompt showing connection state, handlesNEED_INPUTby keeping the cursor on the same line and signalling the main loop to collect inputBrowser REPL (
enkan-devel/enkan-repl.js)ws.onmessagehandlesNEED_INPUT: renders prompt withterm.write(no newline) and unlockswaitingForResponseConfiguration
ENKAN_AI_API_URLenkan.ai.apiUrlhttps://api.anthropic.com/v1/chat/completionsor not — it's normalized.ENKAN_AI_API_KEYenkan.ai.apiKeyENKAN_AI_MODELenkan.ai.modelclaude-sonnet-4-5Usage
Test plan
mvn test -pl enkan-repl-client -am -DexcludedGroups=integrationpasses (27 tests inInitCommandTest, 4 inReplClientLocalHelpTest)enkan-repl, execute/init, verify interactive prompts, plan review, generation, compile-fix loop, auto-launch, and auto-connect all work end-to-endENKAN_AI_API_KEYfor any OpenAI-compatible endpoint🤖 Generated with Claude Code