From 57f74ca62aec86e07fa27b850b59813077c613c3 Mon Sep 17 00:00:00 2001 From: Sylvain Gougouzian Date: Mon, 13 Jul 2026 11:06:04 +0200 Subject: [PATCH] feat(cli): add CLI entry point and commands - Add Clipse-based CLI with subcommands - Add init command with project scaffolding wizard - Add doc command for Diataxis documentation generation - Add obsidian command to open vault in Obsidian - Add run command with DMR model listing - Add tui-main with full event loop, agent loop, and key handling - Add stack-questions prompts for language/framework/services --- src/cli/commands/doc.ts | 29 ++ src/cli/commands/init.ts | 162 +++++++ src/cli/commands/obsidian.ts | 49 ++ src/cli/commands/run.ts | 19 + src/cli/commands/tui-main.ts | 708 +++++++++++++++++++++++++++++ src/cli/index.ts | 48 ++ src/cli/prompts/stack-questions.ts | 234 ++++++++++ 7 files changed, 1249 insertions(+) create mode 100644 src/cli/commands/doc.ts create mode 100644 src/cli/commands/init.ts create mode 100644 src/cli/commands/obsidian.ts create mode 100644 src/cli/commands/run.ts create mode 100644 src/cli/commands/tui-main.ts create mode 100644 src/cli/index.ts create mode 100644 src/cli/prompts/stack-questions.ts diff --git a/src/cli/commands/doc.ts b/src/cli/commands/doc.ts new file mode 100644 index 0000000..bbc6558 --- /dev/null +++ b/src/cli/commands/doc.ts @@ -0,0 +1,29 @@ +import { basename } from "node:path"; +import { intro, outro, spinner } from "@clack/prompts"; +import { + type DiataxisConfig, + generateDiataxisDocs, +} from "../../services/doc-generator"; + +export async function docHandler(opts: { output: string; scope: string }) { + intro("paikea doc — Diátaxis documentation"); + + const config: DiataxisConfig = { + outputDir: opts.output, + scope: opts.scope, + projectName: basename(process.cwd()), + }; + + const s = spinner(); + s.start("Generating Diátaxis documentation..."); + + try { + generateDiataxisDocs(config); + s.stop("Documentation generated!"); + } catch (err) { + s.stop("Error generating documentation"); + console.error(err); + } + + outro(`Documentation written to: ${opts.output}/`); +} diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts new file mode 100644 index 0000000..6d49b00 --- /dev/null +++ b/src/cli/commands/init.ts @@ -0,0 +1,162 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { intro, log, outro } from "@clack/prompts"; +import { AGENTS_MD, RULES, SKILLS } from "../../assets"; +import { generateDevcontainer } from "../../services/devcontainer"; +import { generateDiataxisDocs } from "../../services/doc-generator"; +import { installOpenSpec } from "../../services/openspec-setup"; +import { syncOpenSpecToVault } from "../../services/openspec-sync"; +import { generateProjectStructure } from "../../services/project-scaffold"; +import { askStackQuestions } from "../prompts/stack-questions"; + +export async function initHandler() { + try { + intro("paikea init"); + + const config = await askStackQuestions(); + + log.step("Creating project..."); + generateProjectStructure(config); + + log.step("Generating devcontainer..."); + generateDevcontainer(config); + + log.step("Installing OpenSpec..."); + await installOpenSpec(config.name); + + log.step("Syncing OpenSpec to vault..."); + syncOpenSpecToVault(config.name); + + log.step("Copying config files..."); + copyConfigFiles(config.name); + + log.step("Generating Obsidian vault..."); + generateVault(config.name); + + log.step("Generating Diataxis documentation..."); + generateDiataxisDocs({ + outputDir: join(config.name, "docs"), + scope: join(config.name, "src"), + projectName: config.name, + }); + + outro(`Done! Run: cd ${config.name} && paikea`); + } catch (error) { + outro( + `Error: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`, + ); + } +} + +function generateVault(projectName: string) { + const vaultDir = join(projectName, ".paikea", "vault"); + + mkdirSync(join(vaultDir, "logs"), { recursive: true }); + mkdirSync(join(vaultDir, "skills"), { recursive: true }); + mkdirSync(join(vaultDir, "rules"), { recursive: true }); + mkdirSync(join(vaultDir, "specs"), { recursive: true }); + mkdirSync(join(vaultDir, "templates"), { recursive: true }); + + writeFileSync( + join(vaultDir, "index.md"), + `# ${projectName} Vault + +Generated on ${new Date().toISOString().split("T")[0] ?? new Date().toISOString()} + +## Structure + +- \`logs/\` - Session logs +- \`skills/\` - Skill documentation +- \`rules/\` - Project rules +- \`specs/\` - OpenSpec specifications +- \`templates/\` - Document templates + +## Usage + +This vault is automatically maintained by paikea. Open it with Obsidian: + +\`\`\`bash +obsidian .paikea/vault +\`\`\` +`, + ); + + writeFileSync( + join(vaultDir, "templates", "session-log.md"), + `--- +date: {{date}} +model: {{model}} +tokens_used: {{tokens}} +duration: {{duration}} +--- + +## Session {{date}} + +### Prompt 1 +> {{prompt}} + +**Thinking:** {{thinking}} + +**Response:** {{response}} +`, + ); + + writeFileSync( + join(vaultDir, "templates", "skill-doc.md"), + `--- +name: {{name}} +description: {{description}} +triggers: + - {{trigger}} +--- + +{{content}} +`, + ); + + writeFileSync( + join(vaultDir, "templates", "spec-doc.md"), + `--- +title: {{title}} +status: {{status}} +created: {{date}} +--- + +# {{title}} + +## Description + +{{description}} + +## Acceptance Criteria + +- [ ] Criterion 1 +- [ ] Criterion 2 +- [ ] Criterion 3 + +## Implementation Notes + +{{notes}} +`, + ); +} + +function copyConfigFiles(projectName: string) { + writeFileSync(join(projectName, "AGENTS.md"), AGENTS_MD); + + const skillsDir = join(projectName, ".paikea", "skills"); + mkdirSync(skillsDir, { recursive: true }); + for (const [file, content] of Object.entries(SKILLS)) { + const filePath = join(skillsDir, file); + mkdirSync(join(filePath, ".."), { recursive: true }); + writeFileSync(filePath, content); + } + + const rulesDir = join(projectName, ".paikea", "rules"); + mkdirSync(rulesDir, { recursive: true }); + for (const [file, content] of Object.entries(RULES)) { + const filePath = join(rulesDir, file); + mkdirSync(join(filePath, ".."), { recursive: true }); + writeFileSync(filePath, content); + } +} diff --git a/src/cli/commands/obsidian.ts b/src/cli/commands/obsidian.ts new file mode 100644 index 0000000..cbb0463 --- /dev/null +++ b/src/cli/commands/obsidian.ts @@ -0,0 +1,49 @@ +import { execSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import { outro } from "@clack/prompts"; + +export async function obsidianHandler() { + const vaultPath = join(process.cwd(), ".paikea", "vault"); + + if (!existsSync(vaultPath)) { + outro( + "No vault found. Run `paikea init` first to create a project with vault.", + ); + process.exit(1); + } + + // Check if obsidian CLI is installed + try { + execSync("obsidian --version", { stdio: "ignore" }); + } catch { + outro( + "Obsidian CLI not found.\n\n" + + "Install it:\n" + + " 1. Open Obsidian\n" + + " 2. Go to Settings > Community Plugins\n" + + ' 3. Search for "Obsidian CLI" and install it\n' + + " 4. Or install manually: npm install -g @obisidian/cli\n\n" + + "Then run this command again.", + ); + process.exit(1); + } + + // Sync OpenSpec to vault before opening + try { + const { syncOpenSpecToVault } = await import( + "../../services/openspec-sync" + ); + syncOpenSpecToVault(process.cwd()); + } catch { + // Silent fail — vault will still open + } + + // Open Obsidian with the vault + try { + execSync(`obsidian "${vaultPath}"`, { stdio: "inherit" }); + } catch { + outro("Failed to open Obsidian. Is it running?"); + process.exit(1); + } +} diff --git a/src/cli/commands/run.ts b/src/cli/commands/run.ts new file mode 100644 index 0000000..5628cbe --- /dev/null +++ b/src/cli/commands/run.ts @@ -0,0 +1,19 @@ +import { outro } from "@clack/prompts"; +import { listModels } from "../../services/dmr-client"; +import { startTUI } from "./tui-main"; + +export async function runHandler() { + const models = await listModels(); + + if (models.length === 0) { + outro( + "Docker Model Runner not reachable or no models found.\n\n" + + " 1. Ensure Docker Desktop is running with Model Runner enabled\n" + + " 2. Pull a model: docker model pull ai/qwen3\n\n" + + " Docs: https://docs.docker.com/desktop/features/model-runner/", + ); + process.exit(1); + } + + await startTUI(); +} diff --git a/src/cli/commands/tui-main.ts b/src/cli/commands/tui-main.ts new file mode 100644 index 0000000..619e891 --- /dev/null +++ b/src/cli/commands/tui-main.ts @@ -0,0 +1,708 @@ +import { confirm } from "@clack/prompts"; +import { renderAgentSteps } from "../../components/agent-steps"; +import { renderCommandPalette } from "../../components/command-palette"; +import { renderHeader } from "../../components/header"; +import { renderPromptInput } from "../../components/prompt-input"; +import { renderResultPane } from "../../components/result-pane"; +import { renderStatusBar } from "../../components/status-bar"; +import { renderThinkingPane } from "../../components/thinking-pane"; +import { renderTimeline } from "../../components/timeline"; +import { calculateLayout } from "../../renderer/layout"; +import { + cleanupTerminal, + getEventStream, + getTerminal, + initTerminal, +} from "../../renderer/terminal"; +import { buildRulesPrompt, loadRules } from "../../rules/registry"; +import { streamChat } from "../../services/dmr-client"; +import { + fetchModels, + nextModel, + prevModel, +} from "../../services/model-registry"; +import { detectOpenSpecSteps } from "../../services/openspec-hook"; +import { + computeSuggestions, + getCurrentStepId, +} from "../../services/suggestions"; +import { hasThinkingSupport } from "../../services/thinking-parser"; +import { buildSkillsPrompt, loadSkills } from "../../skills/registry"; +import { createSessionId, saveSession } from "../../state/session"; +import { store } from "../../state/store"; +import { executeTool } from "../../tools/executor"; +import { loadTools } from "../../tools/registry"; +import type { Action, ToolCall, ToolDefinition } from "../../types"; + +const MAX_TOOL_ITERATIONS = 10; + +export async function startTUI() { + const term = await initTerminal(); + if (!term) return; + + const stream = getEventStream(); + if (!stream) return; + + const cwd = process.cwd(); + + const models = await fetchModels(); + if (models.length === 0) { + console.error( + "No models found in Docker Model Runner. Run: docker model pull ai/qwen3", + ); + await cleanupTerminal(); + return; + } + + const steps = detectOpenSpecSteps(cwd); + store.set({ + models, + model: models[0], + steps, + }); + + // Initial suggestions + const stepId = getCurrentStepId(steps); + const initialSuggestions = computeSuggestions("", stepId); + store.set({ suggestions: initialSuggestions }); + + const skills = loadSkills(cwd); + const rules = loadRules(); + const tools = loadTools(cwd); + const systemPrompt = buildSkillsPrompt(skills) + buildRulesPrompt(rules); + + let prompt = ""; + let cursorPos = 0; + const sessionId = createSessionId(); + + let quitting = false; + async function quit() { + if (quitting) return; + quitting = true; + streamAbort?.abort(); + await cleanupTerminal(); + process.exit(0); + } + + // Controls the in-flight generation so it can be cancelled from the keyboard. + let streamAbort: AbortController | null = null; + function cancelStreaming(): boolean { + if (!store.get().streaming) return false; + streamAbort?.abort(); + return true; + } + + // Actions for palette and toolbar + const actions: Action[] = [ + { + id: "model", + label: "Switch Model", + shortcut: "Tab", + action: () => { + const newModel = nextModel(); + if (newModel) { + store.set({ model: newModel, steps: detectOpenSpecSteps(cwd) }); + } + }, + }, + { + id: "thinking", + label: "Toggle Thinking", + shortcut: "", + action: () => + store.set({ thinkingVisible: !store.get().thinkingVisible }), + }, + { + id: "clear", + label: "Clear", + shortcut: "", + action: () => { + store.clearResult(); + store.setThinking({ active: false, content: "", tokens: 0 }); + store.clearAgentSteps(); + store.set({ scrollOffset: 0 }); + }, + }, + { + id: "quit", + label: "Quit", + shortcut: "Esc", + action: () => { + void quit(); + }, + }, + ]; + + function drawUI() { + const s = store.get(); + const { rows, cols } = term; + const layout = calculateLayout( + rows, + cols, + s.thinkingVisible, + s.agentSteps.length, + ); + + renderHeader(term, layout, s.model?.name ?? "none"); + renderTimeline(term, layout, s.steps); + renderThinkingPane(term, layout, s.thinking.content, s.thinkingVisible); + renderAgentSteps(term, layout, s.agentSteps); + renderResultPane(term, layout, s.result, s.scrollOffset); + renderPromptInput( + term, + layout, + prompt, + cursorPos, + s.uiMode === "normal", + s.suggestions, + s.suggestionIndex, + ); + renderStatusBar( + term, + layout, + s.model?.name ?? "none", + s.thinking.tokens, + s.streaming, + s.toolbarIndex, + s.uiMode === "toolbar", + ); + + // Render command palette on top + if (s.uiMode === "palette") { + renderCommandPalette(term, actions, s.paletteIndex, rows, cols); + } + + term.render(); + } + + async function confirmDestructive(message: string): Promise { + const result = await confirm({ message }); + return result === true; + } + + async function handleSubmit() { + if (!prompt.trim() || !store.get().model || store.get().streaming) return; + + const userPrompt = prompt; + prompt = ""; + cursorPos = 0; + + store.addMessage({ + role: "user", + content: userPrompt, + timestamp: Date.now(), + }); + store.set({ + streaming: true, + result: "", + agentSteps: [], + suggestions: [], + scrollOffset: 0, + }); + + const model = store.get().model; + if (!model) return; + const useThinking = hasThinkingSupport(model.id); + + const messages: { + role: string; + content: string; + tool_call_id?: string; + name?: string; + }[] = [{ role: "system", content: systemPrompt }]; + + for (const msg of store.get().messages) { + messages.push({ role: msg.role, content: msg.content }); + } + + streamAbort = new AbortController(); + try { + await runAgentLoop( + messages, + model.id, + useThinking, + tools, + cwd, + drawUI, + confirmDestructive, + streamAbort.signal, + ); + } catch (err) { + // A user-initiated cancel aborts the fetch; keep whatever streamed so far. + if (streamAbort.signal.aborted) { + store.set({ + result: `${store.get().result}\n\n[Generation cancelled]`, + }); + } else { + store.set({ result: `Error: ${err}` }); + } + } finally { + streamAbort = null; + } + + const finalResult = store.get().result; + store.addMessage({ + role: "assistant", + content: finalResult, + timestamp: Date.now(), + }); + + store.set({ streaming: false }); + + // Sync OpenSpec to vault + try { + const { syncOpenSpecToVault } = await import( + "../../services/openspec-sync" + ); + syncOpenSpecToVault(cwd); + } catch { + // Silent fail — vault sync is best-effort + } + + saveSession({ + id: sessionId, + date: new Date().toISOString().split("T")[0] ?? new Date().toISOString(), + model: model.name, + messages: store.get().messages, + }); + + drawUI(); + } + + function executeAction(action: Action | undefined) { + if (!action) return; + action.action(); + store.set({ uiMode: "normal" }); + drawUI(); + } + + (async () => { + for await (const event of stream) { + if (event.type === "key") { + const { code, modifiers } = event; + const s = store.get(); + + // Ctrl+C / Ctrl+D: cancel an in-flight generation first, otherwise + // quit. Raw mode means these never arrive as SIGINT, so we handle the + // bytes ourselves. + if ( + modifiers.ctrl && + typeof code === "object" && + (code.char === "c" || code.char === "d") + ) { + if (cancelStreaming()) { + drawUI(); + continue; + } + await quit(); + continue; + } + + // Palette mode + if (s.uiMode === "palette") { + if (code === "Escape") { + store.set({ uiMode: "normal" }); + drawUI(); + continue; + } + if (code === "Up") { + store.set({ + paletteIndex: Math.max(0, s.paletteIndex - 1), + }); + drawUI(); + continue; + } + if (code === "Down") { + store.set({ + paletteIndex: Math.min(actions.length - 1, s.paletteIndex + 1), + }); + drawUI(); + continue; + } + if (code === "Enter") { + executeAction(actions[s.paletteIndex]); + continue; + } + continue; + } + + // Toolbar mode + if (s.uiMode === "toolbar") { + if (code === "Escape") { + store.set({ uiMode: "normal" }); + drawUI(); + continue; + } + if (code === "Tab") { + store.set({ + toolbarIndex: (s.toolbarIndex + 1) % 4, + }); + drawUI(); + continue; + } + if (code === "Enter") { + // Execute toolbar action by index + const toolbarActions = [ + actions[0], // Model + actions[1], // Thinking + actions[2], // Clear + actions[3], // Quit (palette) + ]; + if (s.toolbarIndex === 3) { + // Palette button opens palette + store.set({ uiMode: "palette", toolbarIndex: 0 }); + } else { + executeAction(toolbarActions[s.toolbarIndex]); + } + drawUI(); + continue; + } + continue; + } + + // Normal mode + if (code === "Escape") { + // Escape cancels a running generation, or quits when idle. + if (cancelStreaming()) { + drawUI(); + continue; + } + await quit(); + continue; + } + + // Enter to submit (only when prompt is focused and not already busy). + // Launched detached so the event loop stays responsive while streaming, + // which is what lets Escape / Ctrl+C cancel mid-generation. + if ( + code === "Enter" && + !modifiers.ctrl && + !modifiers.alt && + s.uiMode === "normal" + ) { + if (!s.streaming) { + void handleSubmit(); + } + drawUI(); + continue; + } + + // Scroll the response pane. + if (code === "PageUp" || (code === "Up" && modifiers.shift)) { + store.set({ scrollOffset: Math.max(0, s.scrollOffset - 5) }); + drawUI(); + continue; + } + if (code === "PageDown" || (code === "Down" && modifiers.shift)) { + store.set({ scrollOffset: s.scrollOffset + 5 }); + drawUI(); + continue; + } + + // Shift+Tab cycles to the previous model. + if (code === "Tab" && modifiers.shift) { + const newModel = prevModel(); + if (newModel) { + store.set({ model: newModel, steps: detectOpenSpecSteps(cwd) }); + } + drawUI(); + continue; + } + + // Tab to accept suggestion or next model + if (code === "Tab" && !modifiers.shift) { + const s2 = store.get(); + if (s2.suggestions.length > 0 && s2.uiMode === "normal") { + // Accept suggestion + const idx = s2.suggestionIndex >= 0 ? s2.suggestionIndex : 0; + const suggestion = s2.suggestions[idx]; + if (suggestion) { + prompt = suggestion; + cursorPos = prompt.length; + } + } else { + const newModel = nextModel(); + if (newModel) { + store.set({ model: newModel, steps: detectOpenSpecSteps(cwd) }); + } + } + drawUI(); + continue; + } + + // : to open palette + if (typeof code === "object" && code.char === ":") { + store.set({ uiMode: "palette", paletteIndex: 0 }); + drawUI(); + continue; + } + + // Arrow keys for toolbar navigation + if (code === "Left") { + if (s.uiMode === "normal") { + cursorPos = Math.max(0, cursorPos - 1); + } + drawUI(); + continue; + } + if (code === "Right") { + if (s.uiMode === "normal") { + cursorPos = Math.min(prompt.length, cursorPos + 1); + } + drawUI(); + continue; + } + + // Cursor up/down to enter toolbar mode + if (code === "Up") { + store.set({ uiMode: "toolbar", toolbarIndex: 0 }); + drawUI(); + continue; + } + if (code === "Down") { + // Down exits toolbar back to prompt + store.set({ uiMode: "normal" }); + drawUI(); + continue; + } + + // Prompt editing (only in normal mode) + if (s.uiMode === "normal") { + let promptChanged = false; + if (code === "Backspace") { + prompt = prompt.slice(0, cursorPos - 1) + prompt.slice(cursorPos); + cursorPos = Math.max(0, cursorPos - 1); + promptChanged = true; + } else if (code === "Delete") { + prompt = prompt.slice(0, cursorPos) + prompt.slice(cursorPos + 1); + promptChanged = true; + } else if ( + code === "Home" || + (modifiers.ctrl && typeof code === "object" && code.char === "a") + ) { + cursorPos = 0; + } else if ( + code === "End" || + (modifiers.ctrl && typeof code === "object" && code.char === "e") + ) { + cursorPos = prompt.length; + } else if ( + modifiers.ctrl && + typeof code === "object" && + code.char === "u" + ) { + // Kill from cursor to line start + prompt = prompt.slice(cursorPos); + cursorPos = 0; + promptChanged = true; + } else if ( + modifiers.ctrl && + typeof code === "object" && + code.char === "w" + ) { + // Kill the word before the cursor + const before = prompt.slice(0, cursorPos); + const trimmed = before.replace(/\s*\S+\s*$/, ""); + prompt = trimmed + prompt.slice(cursorPos); + cursorPos = trimmed.length; + promptChanged = true; + } else if ( + typeof code === "object" && + code.char && + !modifiers.ctrl && + !modifiers.alt + ) { + prompt = + prompt.slice(0, cursorPos) + code.char + prompt.slice(cursorPos); + cursorPos += code.char.length; + promptChanged = true; + } + + // Recompute suggestions on prompt change + if (promptChanged) { + const stepId = getCurrentStepId(s.steps); + const suggestions = computeSuggestions(prompt, stepId); + store.set({ suggestions, suggestionIndex: -1 }); + } + } + + drawUI(); + } + } + })(); + + // Redraw on terminal resize so the layout tracks the new dimensions. + getTerminal()?.on("resize", () => drawUI()); + + drawUI(); + + process.on("SIGINT", () => { + void quit(); + }); + + process.on("SIGTERM", () => { + void quit(); + }); +} + +async function runAgentLoop( + messages: { + role: string; + content: string; + tool_call_id?: string; + name?: string; + }[], + modelId: string, + useThinking: boolean, + tools: ToolDefinition[], + projectDir: string, + drawUI: () => void, + confirmFn: (message: string) => Promise, + signal?: AbortSignal, +) { + let iterations = 0; + + while (iterations < MAX_TOOL_ITERATIONS) { + if (signal?.aborted) return; + iterations++; + let thinkingContent = ""; + let resultContent = ""; + const toolCallsByName = new Map< + string, + { id: string; name: string; argsBuffer: string } + >(); + + if (useThinking) { + for await (const chunk of streamChat(messages, modelId, tools, signal)) { + if (chunk.type === "thinking") { + thinkingContent += chunk.text; + store.setThinking({ + content: thinkingContent, + tokens: thinkingContent.length, + }); + } else if (chunk.type === "content") { + resultContent += chunk.text; + store.set({ result: resultContent }); + } else if (chunk.type === "tool_call") { + accumulateToolCall( + toolCallsByName, + chunk.id, + chunk.name, + chunk.arguments, + ); + } + drawUI(); + } + } else { + for await (const chunk of streamChat(messages, modelId, tools, signal)) { + if (chunk.type === "content") { + resultContent += chunk.text; + store.set({ result: resultContent }); + } else if (chunk.type === "tool_call") { + accumulateToolCall( + toolCallsByName, + chunk.id, + chunk.name, + chunk.arguments, + ); + } + drawUI(); + } + } + + // No tool calls → done + if (toolCallsByName.size === 0) { + store.set({ result: resultContent }); + return; + } + + // Execute each tool call + const completedToolCalls: ToolCall[] = []; + for (const [, acc] of toolCallsByName) { + let args: Record = {}; + try { + args = JSON.parse(acc.argsBuffer) as Record; + } catch { + // malformed args + } + + const toolCall: ToolCall = { + id: acc.id, + name: acc.name, + arguments: args, + }; + completedToolCalls.push(toolCall); + + // Register agent step + const stepId = `step_${Date.now()}_${acc.id}`; + store.addAgentStep({ + id: stepId, + type: "tool_call", + name: acc.name, + arguments: args, + status: "running", + timestamp: Date.now(), + }); + drawUI(); + + // Find tool definition + const toolDef = tools.find((t) => t.name === acc.name); + if (!toolDef) { + store.updateAgentStep(stepId, { + status: "error", + content: `Unknown tool: ${acc.name}`, + }); + continue; + } + + // Execute + const result = await executeTool( + toolCall, + toolDef, + projectDir, + confirmFn, + ); + + // Update step with result + store.updateAgentStep(stepId, { + type: "tool_result", + content: result.success ? result.result : `Error: ${result.result}`, + status: result.success ? "done" : "error", + }); + drawUI(); + + // Feed result back to messages + messages.push({ + role: "assistant", + content: "", + }); + messages.push({ + role: "tool", + tool_call_id: result.toolCallId, + name: result.name, + content: result.result, + }); + } + + // Clear agent steps after a short delay concept — keep them visible + // They'll be cleared on next user message + } + + store.set({ + result: `${store.get().result}\n\n[Max tool iterations reached]`, + }); +} + +function accumulateToolCall( + acc: Map, + id: string, + name: string, + argsChunk: string, +) { + let existing = acc.get(id); + if (!existing) { + existing = { id, name, argsBuffer: "" }; + acc.set(id, existing); + } + existing.argsBuffer += argsChunk; +} diff --git a/src/cli/index.ts b/src/cli/index.ts new file mode 100644 index 0000000..b41bcbc --- /dev/null +++ b/src/cli/index.ts @@ -0,0 +1,48 @@ +import { Clipse } from "clipse"; +import { docHandler } from "./commands/doc"; +import { initHandler } from "./commands/init"; +import { obsidianHandler } from "./commands/obsidian"; +import { runHandler } from "./commands/run"; + +const initCmd = new Clipse( + "init", + "Initialize a new paikea project with devcontainer + OpenSpec", +); +initCmd.action(() => initHandler()); + +const docCmd = new Clipse( + "doc", + "Generate Diátaxis documentation (tutorials, how-to, reference, explanation)", +); +docCmd.addOptions({ + output: { + short: "o", + type: "string", + description: "Output directory for documentation", + default: "./docs", + }, + scope: { + short: "s", + type: "string", + description: "Scope: 'all', 'src', or specific path", + default: "src", + }, +}); +docCmd.action((_a, _o) => docHandler(_o as { output: string; scope: string })); + +const obsidianCmd = new Clipse( + "obsidian", + "Open the project vault in Obsidian (syncs OpenSpec specs first)", +); +obsidianCmd.action(() => obsidianHandler()); + +const cli = new Clipse( + "paikea", + "AI CLI agent — local LLMs via Docker Model Runner", + "0.1.0", +); + +cli + .addSubcommands([initCmd, docCmd, obsidianCmd]) + .action(() => runHandler()) + .ready(); diff --git a/src/cli/prompts/stack-questions.ts b/src/cli/prompts/stack-questions.ts new file mode 100644 index 0000000..3bbd47c --- /dev/null +++ b/src/cli/prompts/stack-questions.ts @@ -0,0 +1,234 @@ +import { cancel, isCancel, multiselect, select, text } from "@clack/prompts"; +import type { ProjectConfig } from "../../types"; + +const FRAMEWORKS_BY_LANG: Record< + string, + { value: string; label: string; hint?: string }[] +> = { + typescript: [ + { value: "nextjs", label: "Next.js", hint: "recommended" }, + { value: "express", label: "Express" }, + { value: "fastify", label: "Fastify" }, + { value: "hono", label: "Hono" }, + { value: "bun", label: "Bun" }, + { value: "nest", label: "NestJS" }, + { value: "astro", label: "Astro" }, + { value: "remix", label: "Remix" }, + { value: "vanilla", label: "Vanilla (no framework)" }, + { value: "custom", label: "Custom (manual input)" }, + ], + python: [ + { value: "fastapi", label: "FastAPI", hint: "recommended" }, + { value: "django", label: "Django" }, + { value: "flask", label: "Flask" }, + { value: "vanilla", label: "Vanilla (no framework)" }, + { value: "custom", label: "Custom (manual input)" }, + ], + go: [ + { value: "gin", label: "Gin", hint: "recommended" }, + { value: "echo", label: "Echo" }, + { value: "fiber", label: "Fiber" }, + { value: "vanilla", label: "Vanilla (no framework)" }, + { value: "custom", label: "Custom (manual input)" }, + ], + rust: [ + { value: "axum", label: "Axum", hint: "recommended" }, + { value: "actix", label: "Actix Web" }, + { value: "rocket", label: "Rocket" }, + { value: "vanilla", label: "Vanilla (no framework)" }, + { value: "custom", label: "Custom (manual input)" }, + ], + ruby: [ + { value: "rails", label: "Rails", hint: "recommended" }, + { value: "sinatra", label: "Sinatra" }, + { value: "vanilla", label: "Vanilla (no framework)" }, + { value: "custom", label: "Custom (manual input)" }, + ], + php: [ + { value: "laravel", label: "Laravel", hint: "recommended" }, + { value: "symfony", label: "Symfony" }, + { value: "vanilla", label: "Vanilla (no framework)" }, + { value: "custom", label: "Custom (manual input)" }, + ], + java: [ + { value: "spring", label: "Spring Boot", hint: "recommended" }, + { value: "quarkus", label: "Quarkus" }, + { value: "vanilla", label: "Vanilla (no framework)" }, + { value: "custom", label: "Custom (manual input)" }, + ], + dotnet: [ + { value: "aspnet", label: "ASP.NET Core", hint: "recommended" }, + { value: "vanilla", label: "Vanilla (no framework)" }, + { value: "custom", label: "Custom (manual input)" }, + ], +}; + +export async function askStackQuestions(): Promise { + const name = await text({ + message: "Project name?", + placeholder: "my-project", + validate: (v) => { + if (!v) return "Required"; + if (!/^[a-z0-9-]+$/.test(v)) + return "Lowercase letters, numbers, and hyphens only"; + return undefined; + }, + }); + if (isCancel(name) || !name) { + cancel("Operation cancelled."); + process.exit(0); + } + + const lang = await select({ + message: "Language / Runtime?", + options: [ + { + value: "typescript", + label: "TypeScript / Node.js / Bun", + hint: "recommended", + }, + { value: "python", label: "Python" }, + { value: "go", label: "Go" }, + { value: "rust", label: "Rust" }, + { value: "ruby", label: "Ruby" }, + { value: "php", label: "PHP" }, + { value: "java", label: "Java" }, + { value: "dotnet", label: ".NET / C#" }, + { value: "custom", label: "Custom (manual input)" }, + ], + }); + if (isCancel(lang) || !lang) { + cancel("Operation cancelled."); + process.exit(0); + } + + let framework: string; + + if (lang === "custom") { + const customLang = await text({ + message: "Language / runtime name?", + placeholder: "e.g. zig, swift, kotlin...", + validate: (v) => (v ? undefined : "Required"), + }); + if (isCancel(customLang) || !customLang) { + cancel("Operation cancelled."); + process.exit(0); + } + framework = customLang; + + const customFramework = await text({ + message: "Framework? (leave empty for none)", + placeholder: "e.g. actix, gin, fastapi...", + }); + if (isCancel(customFramework) || customFramework === undefined) { + cancel("Operation cancelled."); + process.exit(0); + } + framework = customFramework || "vanilla"; + } else { + const frameworkChoice = await select({ + message: "Framework?", + options: FRAMEWORKS_BY_LANG[lang] ?? [], + }); + if (isCancel(frameworkChoice) || !frameworkChoice) { + cancel("Operation cancelled."); + process.exit(0); + } + + if (frameworkChoice === "custom") { + const customFramework = await text({ + message: "Framework name?", + placeholder: "e.g. express, fastapi, gin...", + validate: (v) => (v ? undefined : "Required"), + }); + if (isCancel(customFramework) || !customFramework) { + cancel("Operation cancelled."); + process.exit(0); + } + framework = customFramework; + } else { + framework = frameworkChoice; + } + } + + const database = await multiselect({ + message: "Databases?", + options: [ + { value: "none", label: "None" }, + { value: "postgres", label: "PostgreSQL" }, + { value: "mysql", label: "MySQL" }, + { value: "mongodb", label: "MongoDB" }, + { value: "redis", label: "Redis" }, + { value: "sqlite", label: "SQLite" }, + { value: "custom", label: "Custom (manual input)" }, + ], + required: false, + }); + if (isCancel(database) || database === undefined) { + cancel("Operation cancelled."); + process.exit(0); + } + + const finalDatabase = database.filter((d) => d !== "custom"); + if (database.includes("custom")) { + const customDb = await text({ + message: "Database name?", + placeholder: "e.g. cockroachdb, mariadb, dynamodb...", + validate: (v) => (v ? undefined : "Required"), + }); + if (isCancel(customDb) || !customDb) { + cancel("Operation cancelled."); + process.exit(0); + } + finalDatabase.push( + customDb as + | "postgres" + | "mysql" + | "mongodb" + | "redis" + | "none" + | "sqlite", + ); + } + + const services = await multiselect({ + message: "Additional services?", + options: [ + { value: "redis", label: "Redis" }, + { value: "meilisearch", label: "Meilisearch" }, + { value: "rabbitmq", label: "RabbitMQ" }, + { value: "mailhog", label: "MailHog" }, + { value: "custom", label: "Custom (manual input)" }, + ], + required: false, + }); + if (isCancel(services) || services === undefined) { + cancel("Operation cancelled."); + process.exit(0); + } + + const finalServices = services.filter((s) => s !== "custom"); + if (services.includes("custom")) { + const customService = await text({ + message: "Service name?", + placeholder: "e.g. minio, vault, consul...", + validate: (v) => (v ? undefined : "Required"), + }); + if (isCancel(customService) || !customService) { + cancel("Operation cancelled."); + process.exit(0); + } + finalServices.push( + customService as "redis" | "meilisearch" | "rabbitmq" | "mailhog", + ); + } + + return { + name, + lang, + framework, + database: finalDatabase, + services: finalServices, + port: 3000, + }; +} -- 2.51.2