diff --git a/lat.md/cli.md b/lat.md/cli.md index 19753d0..be0e157 100644 --- a/lat.md/cli.md +++ b/lat.md/cli.md @@ -148,12 +148,13 @@ Usage: `lat init [dir]` Steps: 1. **lat.md/ directory** — if not present, asks whether to create it (via a one-off readline interface that is closed before step 2). Scaffolds from `templates/init/` (`.gitignore` and `README.md`). If it already exists, skips ahead. -2. **Agent selection** — interactive arrow-key select menu ([[src/cli/select-menu.ts#selectMenu]]). Users pick agents one at a time; after each selection, the menu reappears without that agent and with a "This is it: continue" option (green background accent) at the top. On the first prompt the cursor defaults to the first agent; on subsequent prompts it defaults to "This is it: continue". Supports up/down arrows, j/k, Enter to confirm, Ctrl+C to abort. **Important:** the persistent readline interface is created _after_ this step — `selectMenu` puts stdin into raw mode with its own `data` listener, which corrupts any co-existing readline interface. +2. **Agent selection** — interactive checklist menu ([[src/cli/checklist-menu.ts#checklistMenu]]). All agents are shown at once with `[x]`/`[ ]` checkboxes; the cursor row is highlighted with `chalk.bgCyan`. Keys: up/down (j/k) to move, Space to toggle, Enter to confirm, Ctrl+C to abort. Returns an array of selected agent values. Non-TTY fallback returns `[]`. After confirmation, prints a summary line (e.g. "Selected: Claude Code, Cursor" or dim "None"). **Important:** the persistent readline interface is created _after_ this step — `checklistMenu` puts stdin into raw mode with its own `data` listener, which corrupts any co-existing readline interface. 3. **Command style** — if any selected agent needs a lat command reference (all except Codex), a `selectMenu` asks "How should agents run lat?" with three options: `lat` (global install, portable), the resolved local binary path, or `npx lat.md@latest` (slow but zero-install). The choice determines what command string is written into hooks, MCP configs, and Pi extensions. Non-interactive mode defaults to `local`. Choosing `global` or `npx` makes generated config files portable and safe to commit. -4. **AGENTS.md** — created if a non-Claude agent is selected (Cursor, Copilot, Codex). Shared instruction file. +4. **AGENTS.md** — created if a non-Claude agent is selected (Cursor, Copilot, Codex). Shared instruction file. Uses marker-based append mode (see below). 5. **Per-agent setup** — configures each selected agent (see subsections below). Each step prints a brief explanation of _why_ it's needed (e.g. why a hook is used instead of CLAUDE.md, why MCP is registered alongside CLI access). 6. **LLM key setup** — checks for an existing key (env var or [[cli#Configuration File]]), and if missing, interactively prompts the user to paste one. Explains what semantic search is and why a key is needed before asking. 7. **Version stamp + file hashes** — writes `INIT_VERSION` and SHA-256 hashes of all template-generated files to `lat.md/.cache/lat_init.json`. On re-run, compares current file content against stored hashes: unmodified files are silently updated to the latest template; user-modified files trigger a Y/n prompt offering to overwrite with the latest template, declining suggests [[cli#gen]]. +8. **Next steps** — after all setup completes, prints agent-specific guidance for having the agent document the codebase. For Claude Code, shows a runnable `claude "..."` command. For IDE agents (Cursor, Copilot, Pi, OpenCode, Codex), shows the prompt to paste into agent chat. Both suggest running `lat check` when done. At the very end, after all steps complete, init checks whether ripgrep (`rg`) is available. If missing, prints a tip suggesting the user install it for faster code scanning, with a link to the ripgrep installation guide. @@ -163,7 +164,7 @@ At the very start, before any steps, init prints the ASCII `lat.md` logo (cyan, Sets up `CLAUDE.md` and two agent hooks for the Claude Code coding agent. -- `CLAUDE.md` — written directly from the template (not a symlink) +- `CLAUDE.md` — written using marker-based append mode (see below), preserving any user content outside the `%% lat:begin %%` / `%% lat:end %%` markers - Hooks synced in `.claude/settings.json` — on every run, all existing lat-owned hook entries are removed, then fresh entries are added for both events. Detection uses three heuristics: `/\blat\b/` in the command string, `hook claude ` substring (catches any install path), or command starting with the current binary path. Non-lat hooks are preserved. Both hooks call [[cli#hook]]: - `UserPromptSubmit` → `lat hook claude UserPromptSubmit` — injects lat.md workflow reminders, auto-resolves `[[refs]]` in the prompt - `Stop` → `lat hook claude Stop` — reminds the agent to update `lat.md/` before finishing @@ -195,7 +196,7 @@ The `.cursor` directory is added to `.gitignore` because its hooks and MCP confi Sets up `copilot-instructions.md` and registers the MCP server for VS Code Copilot. -- `.github/copilot-instructions.md` — static instructions file +- `.github/copilot-instructions.md` — instructions file written using marker-based append mode, preserving any user content outside the markers - [[cli#mcp]] server registered in `.vscode/mcp.json` - `.agents/skills/lat-md/SKILL.md` — skill spec for authoring `lat.md/` files, placed in the cross-agent standard skills directory @@ -222,7 +223,13 @@ All setup steps are idempotent — existing configuration is detected and skippe `.gitignore` entries are only added if the target path is not already tracked in git (`git ls-files`); if tracked, the step prints a warning and skips to avoid a no-op ignore rule. -Implementation: [[src/cli/init.ts]], interactive menu in [[src/cli/select-menu.ts]], version tracking in [[src/init-version.ts]] +### Marker-based append mode + +Shared files use `appendTemplateSection` to preserve user content outside lat's managed section. + +Template content is wrapped in visible `%% lat:begin %%` / `%% lat:end %%` markers. Applies to CLAUDE.md, AGENTS.md, and `.github/copilot-instructions.md`. On re-run: if markers exist and the section matches, it's skipped ("already up to date"); if the section matches the stored hash (unmodified by user), it's replaced in-place; if the user edited the section, init asks before replacing. If the file exists but has no markers (old full-overwrite init), and the full-file hash matches the stored hash, the existing content is migrated to marker format in-place. If the file has user content and no markers, the section is appended to the end. All other agent files (rules, skills, hooks, extensions, plugins) still use full-file `writeTemplateFile` since lat owns those entirely. + +Implementation: [[src/cli/init.ts]], checklist menu in [[src/cli/checklist-menu.ts]], single-select menu in [[src/cli/select-menu.ts]], version tracking in [[src/init-version.ts]] ## Configuration File diff --git a/src/cli/checklist-menu.ts b/src/cli/checklist-menu.ts new file mode 100644 index 0000000..90085b4 --- /dev/null +++ b/src/cli/checklist-menu.ts @@ -0,0 +1,130 @@ +import chalk from 'chalk'; + +export interface ChecklistOption { + label: string; + value: string; +} + +/** + * Display an interactive multi-select checklist with arrow-key navigation. + * Returns an array of checked values. + * + * Keys: Up/Down (j/k) to move, Space to toggle, Enter to confirm, Ctrl+C to exit. + * Non-TTY fallback: returns []. + */ +export async function checklistMenu( + options: ChecklistOption[], + prompt?: string, +): Promise { + if (options.length === 0) return []; + if (!process.stdin.isTTY) return []; + + return new Promise((resolve) => { + let cursor = 0; + const checked = new Set(); + const stdin = process.stdin; + + const wasRaw = stdin.isRaw; + + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding('utf-8'); + + function render() { + process.stdout.write('\x1B[?25l'); // hide cursor + + const lines: string[] = []; + if (prompt) { + lines.push(chalk.bold(prompt)); + } + for (let i = 0; i < options.length; i++) { + const opt = options[i]; + const selected = i === cursor; + const box = checked.has(i) ? '[x]' : '[ ]'; + if (selected) { + lines.push(` ${box} ${chalk.bgCyan.black.bold(` ${opt.label} `)}`); + } else { + lines.push(` ${box} ${chalk.dim(opt.label)}`); + } + } + lines.push(''); + lines.push(chalk.dim(' space: toggle enter: confirm')); + process.stdout.write(lines.join('\n') + '\n'); + } + + function clearRender() { + const totalLines = options.length + (prompt ? 1 : 0) + 2; // +2 for blank line + hint + for (let i = 0; i < totalLines; i++) { + process.stdout.write('\x1B[A\x1B[2K'); + } + } + + function cleanup() { + stdin.setRawMode(wasRaw ?? false); + stdin.pause(); + process.stdout.write('\x1B[?25h'); // show cursor + stdin.removeListener('data', onData); + } + + function onData(data: string | Buffer) { + const key = data.toString(); + + // Ctrl+C + if (key === '\x03') { + clearRender(); + cleanup(); + console.log(''); + process.exit(130); + } + + // Enter — confirm + if (key === '\r' || key === '\n') { + clearRender(); + cleanup(); + const result = [...checked].sort().map((i) => options[i].value); + // Print summary + if (prompt) { + const labels = [...checked] + .sort() + .map((i) => options[i].label) + .join(', '); + console.log( + chalk.bold(prompt) + + ' ' + + (labels ? chalk.green(labels) : chalk.dim('None')), + ); + } + resolve(result); + return; + } + + // Space — toggle + if (key === ' ') { + clearRender(); + if (checked.has(cursor)) { + checked.delete(cursor); + } else { + checked.add(cursor); + } + render(); + return; + } + + // Arrow keys + if (key === '\x1B[A' || key === 'k') { + // Up + clearRender(); + cursor = (cursor - 1 + options.length) % options.length; + render(); + } else if (key === '\x1B[B' || key === 'j') { + // Down + clearRender(); + cursor = (cursor + 1) % options.length; + render(); + } + } + + stdin.on('data', onData); + render(); + }); +} diff --git a/src/cli/init.ts b/src/cli/init.ts index 1b54d5e..03f792f 100644 --- a/src/cli/init.ts +++ b/src/cli/init.ts @@ -26,6 +26,7 @@ import { import { writeInitMeta, readFileHash, contentHash } from '../init-version.js'; import { getLocalVersion, fetchLatestVersion } from '../version.js'; import { selectMenu, type SelectOption } from './select-menu.js'; +import { checklistMenu } from './checklist-menu.js'; async function confirm( rl: ReturnType, @@ -481,6 +482,129 @@ async function writeTemplateFile( return null; } +// ── Marker-based append for shared files ───────────────────────────── + +const MARKER_BEGIN = '%% lat:begin %%'; +const MARKER_END = '%% lat:end %%'; + +/** + * Extract the content between lat markers in a file's text. + * Returns null if markers are not found. + */ +function extractMarkerSection(content: string): string | null { + const beginIdx = content.indexOf(MARKER_BEGIN); + const endIdx = content.indexOf(MARKER_END); + if (beginIdx === -1 || endIdx === -1 || endIdx <= beginIdx) return null; + return content.slice(beginIdx + MARKER_BEGIN.length + 1, endIdx); +} + +/** + * Wrap template content with lat markers. + */ +function wrapWithMarkers(template: string): string { + return `${MARKER_BEGIN}\n${template}${template.endsWith('\n') ? '' : '\n'}${MARKER_END}\n`; +} + +/** + * Write a template into a marker-fenced section of a file, preserving + * any user content outside the markers. + * + * Returns the hash of the written template content, or null if skipped. + */ +async function appendTemplateSection( + root: string, + latDir: string, + relPath: string, + template: string, + label: string, + indent: string, + ask: (message: string) => Promise, +): Promise { + const absPath = join(root, relPath); + const templateHash = contentHash(template); + const wrapped = wrapWithMarkers(template); + + if (!existsSync(absPath)) { + mkdirSync(join(absPath, '..'), { recursive: true }); + writeFileSync(absPath, wrapped); + console.log(chalk.green(`${indent}Created ${label}`)); + return templateHash; + } + + const currentContent = readFileSync(absPath, 'utf-8'); + const existingSection = extractMarkerSection(currentContent); + + if (existingSection !== null) { + // File has markers — compare the section content + const existingSectionHash = contentHash(existingSection); + + if (existingSectionHash === templateHash) { + console.log(chalk.green(`${indent}${label}`) + ' already up to date'); + return templateHash; + } + + // Check if section matches stored hash (unmodified by user) + const storedHash = readFileHash(latDir, relPath); + if (storedHash && existingSectionHash === storedHash) { + // User hasn't edited the section — safe to replace + const beginIdx = currentContent.indexOf(MARKER_BEGIN); + const endIdx = currentContent.indexOf(MARKER_END) + MARKER_END.length; + // Include trailing newline if present + const endWithNl = currentContent[endIdx] === '\n' ? endIdx + 1 : endIdx; + const updated = + currentContent.slice(0, beginIdx) + + wrapped + + currentContent.slice(endWithNl); + writeFileSync(absPath, updated); + console.log(chalk.green(`${indent}Updated ${label}`)); + return templateHash; + } + + // User edited the section — ask before replacing + console.log( + chalk.yellow(`${indent}${label}`) + ' lat section has been modified.', + ); + if (await ask(`${indent}Replace lat section with latest template?`)) { + const beginIdx = currentContent.indexOf(MARKER_BEGIN); + const endIdx = currentContent.indexOf(MARKER_END) + MARKER_END.length; + const endWithNl = currentContent[endIdx] === '\n' ? endIdx + 1 : endIdx; + const updated = + currentContent.slice(0, beginIdx) + + wrapped + + currentContent.slice(endWithNl); + writeFileSync(absPath, updated); + console.log(chalk.green(`${indent}Updated ${label}`)); + return templateHash; + } + + console.log(chalk.dim(`${indent}Kept existing section.`)); + return null; + } + + // No markers — file exists from old init or user-created + // Check if full file matches stored hash (old full-overwrite init, unedited) + const currentHash = contentHash(currentContent); + const storedHash = readFileHash(latDir, relPath); + + if (storedHash && currentHash === storedHash) { + // Unmodified old-style file — migrate: wrap existing content with markers + writeFileSync(absPath, wrapWithMarkers(currentContent)); + console.log( + chalk.green(`${indent}Migrated ${label}`) + ' to marker format', + ); + // Return hash of what's now in the section (the old content) + return currentHash; + } + + // File has user content and no markers — append section + let content = currentContent; + if (!content.endsWith('\n')) content += '\n'; + content += '\n' + wrapped; + writeFileSync(absPath, content); + console.log(chalk.green(`${indent}Appended lat section to ${label}`)); + return templateHash; +} + // ── Shared skill setup ─────────────────────────────────────────────── async function writeAgentsSkill( @@ -519,12 +643,11 @@ async function setupAgentsMd( hashes: Record, ask: (message: string) => Promise, ): Promise { - const hash = await writeTemplateFile( + const hash = await appendTemplateSection( root, latDir, 'AGENTS.md', template, - 'agents.md', 'AGENTS.md', '', ask, @@ -540,13 +663,12 @@ async function setupClaudeCode( ask: (message: string) => Promise, style: LatCommandStyle, ): Promise { - // CLAUDE.md — written directly (not a symlink) - const hash = await writeTemplateFile( + // CLAUDE.md — append-mode with markers (preserves user content) + const hash = await appendTemplateSection( root, latDir, 'CLAUDE.md', template, - 'claude.md', 'CLAUDE.md', ' ', ask, @@ -706,13 +828,12 @@ async function setupCopilot( ask: (message: string) => Promise, style: LatCommandStyle, ): Promise { - // .github/copilot-instructions.md - const hash = await writeTemplateFile( + // .github/copilot-instructions.md — append-mode with markers + const hash = await appendTemplateSection( root, latDir, '.github/copilot-instructions.md', readAgentsTemplate(), - 'agents.md', 'Instructions (.github/copilot-instructions.md)', ' ', ask, @@ -1021,6 +1142,44 @@ async function setupLlmKey( console.log(chalk.green(' Key saved') + ' to ' + chalk.dim(getConfigPath())); } +// ── Post-onboarding guidance ───────────────────────────────────────── + +const NEXT_STEP_PROMPT = + 'Read through this codebase and set up lat.md/ to document its architecture, key design decisions, and domain concepts. Run `lat check` when done.'; + +function printNextSteps(selectedAgents: string[]): void { + const hasClaudeCode = selectedAgents.includes('claude'); + const ideAgents = selectedAgents.filter((a) => a !== 'claude'); + + const ideLabels: Record = { + cursor: 'Cursor', + copilot: 'VS Code Copilot', + pi: 'Pi', + opencode: 'OpenCode', + codex: 'Codex', + }; + + if (!hasClaudeCode && ideAgents.length === 0) return; + + console.log(''); + console.log( + chalk.bold('Next step') + ' — have your agent document this codebase:', + ); + + if (hasClaudeCode) { + console.log(''); + console.log(' ' + chalk.bold('Claude Code:')); + console.log(' ' + chalk.cyan(`claude "${NEXT_STEP_PROMPT}"`)); + } + + if (ideAgents.length > 0) { + const names = ideAgents.map((a) => ideLabels[a] || a).join(' / '); + console.log(''); + console.log(' ' + chalk.bold(`${names}`) + ' — paste into agent chat:'); + console.log(' ' + chalk.cyan(NEXT_STEP_PROMPT)); + } +} + // ── Main init flow ─────────────────────────────────────────────────── export function readLogo(): string { @@ -1096,7 +1255,7 @@ export async function initCmd(targetDir?: string): Promise { // Step 2: Which coding agents do you use? (interactive select menu) console.log(''); - const allAgents: SelectOption[] = [ + const allAgents = [ { label: 'Claude Code', value: 'claude' }, { label: 'Pi', value: 'pi' }, { label: 'Cursor', value: 'cursor' }, @@ -1105,37 +1264,10 @@ export async function initCmd(targetDir?: string): Promise { { label: 'Codex', value: 'codex' }, ]; - const selectedAgents: string[] = []; - - // Iterative selection: pick agents one at a time until "done" - while (true) { - const remaining = allAgents.filter( - (a) => !selectedAgents.includes(a.value), - ); - const options: SelectOption[] = [ - { - label: - selectedAgents.length === 0 - ? "I don't use any of these" - : 'This is it: continue', - value: '__done__', - accent: true, - }, - ...remaining, - ]; - - const isFirst = selectedAgents.length === 0; - const choice = await selectMenu( - options, - isFirst ? 'Which coding agent do you use?' : 'Add another agent?', - isFirst ? 1 : 0, - ); - - if (!choice || choice === '__done__') break; - selectedAgents.push(choice); - - if (remaining.length === 1) break; // all agents selected - } + const selectedAgents = await checklistMenu( + allAgents, + 'Which coding agents do you use?', + ); const useClaudeCode = selectedAgents.includes('claude'); const usePi = selectedAgents.includes('pi'); @@ -1276,6 +1408,9 @@ export async function initCmd(targetDir?: string): Promise { chalk.underline('https://github.com/BurntSushi/ripgrep#installation'), ); } + + // Post-onboarding: suggest having the agent document the codebase + printNextSteps(selectedAgents); } finally { rl?.close(); }