From e88e889e64f53e51a99f1acb8e4f02a5a78b8a83 Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Fri, 20 Mar 2026 08:01:13 -0700 Subject: [PATCH 1/2] docs: promote init wizard as recommended Quick Start option The interactive setup wizard (npx preflight-dev init) was buried in a footnote under Option C, despite being the most user-friendly onboarding path. Moved it to Option A with a clear description of what it does. Renumbered remaining options accordingly. --- README.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 6d03f5d..9472e34 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,18 @@ The pattern is always the same: vague prompt → Claude guesses → wrong output ## Quick Start -### Option A: npx (fastest — no install) +### Option A: Interactive setup (recommended) + +The init wizard creates your `.mcp.json` and `.preflight/` config in one step: + +```bash +cd /path/to/your/project +npx preflight-dev init +``` + +It walks you through profile selection (minimal/standard/full), embedding provider, and config directory setup — then writes everything for you. Just restart Claude Code when it's done. + +### Option B: One-liner (fastest — no wizard) ```bash claude mcp add preflight -- npx -y preflight-dev-serve @@ -90,7 +101,7 @@ claude mcp add preflight \ -- npx -y preflight-dev-serve ``` -### Option B: Clone & configure manually +### Option C: Clone & configure manually ```bash git clone https://github.com/TerminalGravity/preflight.git @@ -115,7 +126,7 @@ Add to your project's `.mcp.json`: Restart Claude Code. The tools activate automatically. -### Option C: npm (global) +### Option D: npm (global) ```bash npm install -g preflight-dev From 363ce120bea9ddbc07f3b596621258c98e687900 Mon Sep 17 00:00:00 2001 From: Jack Felke Date: Fri, 20 Mar 2026 08:17:17 -0700 Subject: [PATCH 2/2] fix: replace shell syntax in run() calls with safe array args (#302) run() uses execFileSync without a shell, so shell operators like 2>/dev/null, pipes, &&, and redirects were passed as literal args, causing silent failures across 8 tool files. Changes: - Add exec(), countLines(), countBytes(), headLines() helpers to git.ts - Convert all run() calls to use array args for git commands - Replace shell pipelines (grep, wc, head, tail) with Node.js equivalents - Fix checkpoint.ts to use separate run() calls instead of && chaining - Replace 'command -v' with 'which' via exec() helper Fixes #302 --- src/lib/git.ts | 60 ++++++++++++++++++++++++++++++++++ src/tools/audit-workspace.ts | 8 +++-- src/tools/checkpoint.ts | 35 +++++++++----------- src/tools/clarify-intent.ts | 15 ++++++--- src/tools/enrich-agent-task.ts | 31 +++++++++++++----- src/tools/sequence-tasks.ts | 5 ++- src/tools/session-handoff.ts | 8 ++--- src/tools/sharpen-followup.ts | 16 ++++----- src/tools/token-audit.ts | 19 ++++------- 9 files changed, 135 insertions(+), 62 deletions(-) diff --git a/src/lib/git.ts b/src/lib/git.ts index a32ee3c..bac4fb3 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -1,7 +1,67 @@ import { execFileSync } from "child_process"; +import { readFileSync } from "fs"; import { PROJECT_DIR } from "./files.js"; import type { RunError } from "../types.js"; +/** + * Run an arbitrary command safely using execFileSync (no shell). + * Returns stdout on success, error string on failure. + */ +export function exec(cmd: string, args: string[], opts: { timeout?: number; cwd?: string } = {}): string { + try { + return execFileSync(cmd, args, { + cwd: opts.cwd || PROJECT_DIR, + encoding: "utf-8", + timeout: opts.timeout || 10000, + maxBuffer: 1024 * 1024, + stdio: ["pipe", "pipe", "pipe"], + }).trim(); + } catch (e: any) { + if (e.killed === true || e.signal === "SIGTERM") { + return `[timed out after ${opts.timeout || 10000}ms]`; + } + const output = e.stdout?.trim() || e.stderr?.trim(); + if (output) return output; + if (e.code === "ENOENT") return `[${cmd} not found]`; + return `[command failed: ${cmd} ${args.join(" ")} (exit ${e.status ?? "?"})]`; + } +} + +/** + * Count lines in a file using Node.js (replaces `wc -l < file`). + */ +export function countLines(filePath: string): number { + try { + const content = readFileSync(filePath, "utf-8"); + return content.split("\n").length - (content.endsWith("\n") ? 1 : 0); + } catch { + return 0; + } +} + +/** + * Count bytes in a file using Node.js (replaces `wc -c < file`). + */ +export function countBytes(filePath: string): number { + try { + return readFileSync(filePath).length; + } catch { + return 0; + } +} + +/** + * Read first N lines of a file (replaces `head -N file`). + */ +export function headLines(filePath: string, n: number): string { + try { + const content = readFileSync(filePath, "utf-8"); + return content.split("\n").slice(0, n).join("\n"); + } catch { + return "could not read file"; + } +} + /** * Run a git command safely using execFileSync (no shell injection). * Accepts an array of args (preferred) or a string (split on whitespace for backward compat). diff --git a/src/tools/audit-workspace.ts b/src/tools/audit-workspace.ts index d4306bd..cc75471 100644 --- a/src/tools/audit-workspace.ts +++ b/src/tools/audit-workspace.ts @@ -36,7 +36,8 @@ export function registerAuditWorkspace(server: McpServer): void { {}, async () => { const docs = findWorkspaceDocs(); - const recentFiles = run("git diff --name-only HEAD~10 2>/dev/null || echo ''").split("\n").filter(Boolean); + const recentFilesRaw = run(["diff", "--name-only", "HEAD~10"]); + const recentFiles = (recentFilesRaw.startsWith("[") ? "" : recentFilesRaw).split("\n").filter(Boolean); const sections: string[] = []; // Doc freshness @@ -75,7 +76,10 @@ export function registerAuditWorkspace(server: McpServer): void { // Check for gap trackers or similar tracking docs const trackingDocs = Object.entries(docs).filter(([n]) => /gap|track|progress/i.test(n)); if (trackingDocs.length > 0) { - const testFilesCount = parseInt(run("find tests -name '*.spec.ts' -o -name '*.test.ts' 2>/dev/null | wc -l").trim()) || 0; + const allTracked = run(["ls-files"]); + const testFilesCount = allTracked && !allTracked.startsWith("[") + ? allTracked.split("\n").filter(f => /\.(spec|test)\.(ts|tsx)$/.test(f)).length + : 0; sections.push(`## Tracking Docs\n${trackingDocs.map(([n]) => { const age = docStatus.find(d => d.name === n)?.ageHours ?? "?"; return `- .claude/${n} — last updated ${age}h ago`; diff --git a/src/tools/checkpoint.ts b/src/tools/checkpoint.ts index e086f01..b6bfbb9 100644 --- a/src/tools/checkpoint.ts +++ b/src/tools/checkpoint.ts @@ -63,32 +63,27 @@ ${dirty || "clean"} const shortSummary = summary.split("\n")[0].slice(0, 72); const commitMsg = `checkpoint: ${shortSummary}`; - let addCmd: string; - switch (mode) { - case "staged": { - const staged = getStagedFiles(); - if (!staged) { - commitResult = "nothing staged — skipped commit (use 'tracked' or 'all' mode, or stage files first)"; - } - addCmd = "true"; // noop, already staged - break; + if (mode === "staged") { + const staged = getStagedFiles(); + if (!staged) { + commitResult = "nothing staged — skipped commit (use 'tracked' or 'all' mode, or stage files first)"; } - case "all": - addCmd = "git add -A"; - break; - case "tracked": - default: - addCmd = "git add -u"; - break; } if (commitResult === "no uncommitted changes") { // Stage the checkpoint file too - run(`git add "${checkpointFile}"`); - const result = run(`${addCmd} && git commit -m "${commitMsg.replace(/"/g, '\\"')}" 2>&1`); - if (result.includes("commit failed") || result.includes("nothing to commit")) { + run(["add", checkpointFile]); + // Stage files based on mode + if (mode === "all") { + run(["add", "-A"]); + } else if (mode === "tracked") { + run(["add", "-u"]); + } + // mode === "staged": already staged, noop + const result = run(["commit", "-m", commitMsg]); + if (result.includes("nothing to commit") || result.startsWith("[command failed")) { // Rollback: unstage if commit failed - run("git reset HEAD 2>/dev/null"); + run(["reset", "HEAD"]); commitResult = `commit failed: ${result}`; } else { commitResult = result; diff --git a/src/tools/clarify-intent.ts b/src/tools/clarify-intent.ts index 32efa3a..cd9dd46 100644 --- a/src/tools/clarify-intent.ts +++ b/src/tools/clarify-intent.ts @@ -1,6 +1,6 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { run, getBranch, getStatus, getRecentCommits, getDiffFiles, getStagedFiles } from "../lib/git.js"; +import { run, exec, getBranch, getStatus, getRecentCommits, getDiffFiles, getStagedFiles } from "../lib/git.js"; import { findWorkspaceDocs, PROJECT_DIR } from "../lib/files.js"; import { searchSemantic } from "../lib/timeline-db.js"; import { getRelatedProjects } from "../lib/config.js"; @@ -152,10 +152,15 @@ export function registerClarifyIntent(server: McpServer): void { let hasTestFailures = false; if (!area || area.includes("test") || area.includes("fix") || area.includes("ui") || area.includes("api")) { - const typeErrors = run("pnpm tsc --noEmit 2>&1 | grep -c 'error TS' || echo '0'"); - hasTypeErrors = parseInt(typeErrors, 10) > 0; - - const testFiles = run("find tests -name '*.spec.ts' -maxdepth 4 2>/dev/null | head -20"); + const typeErrorOutput = exec("pnpm", ["tsc", "--noEmit"], { timeout: 30000 }); + const typeErrorCount = (typeErrorOutput.match(/error TS/g) || []).length; + const typeErrors = String(typeErrorCount); + hasTypeErrors = typeErrorCount > 0; + + const testFilesRaw = run(["ls-files"]); + const testFiles = testFilesRaw && !testFilesRaw.startsWith("[") + ? testFilesRaw.split("\n").filter(f => /^tests?\//.test(f) && f.endsWith(".spec.ts")).slice(0, 20).join("\n") + : ""; const failingTests = getTestFailures(); hasTestFailures = failingTests !== "all passing" && failingTests !== "no test report found"; diff --git a/src/tools/enrich-agent-task.ts b/src/tools/enrich-agent-task.ts index 236edfa..1457d56 100644 --- a/src/tools/enrich-agent-task.ts +++ b/src/tools/enrich-agent-task.ts @@ -1,11 +1,12 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { run, getDiffFiles } from "../lib/git.js"; +import { run, getDiffFiles, headLines } from "../lib/git.js"; import { PROJECT_DIR } from "../lib/files.js"; import { getConfig, type RelatedProject } from "../lib/config.js"; import { existsSync, readFileSync } from "fs"; import { execFileSync } from "child_process"; import { join, basename } from "path"; +import { resolve } from "path"; import { createHash } from "crypto"; /** Sanitize user input for safe use in shell commands */ @@ -29,12 +30,19 @@ function findAreaFiles(area: string): string { // If area looks like a path, search directly if (area.includes("/")) { - return run(`git ls-files -- '${safeArea}*' 2>/dev/null | head -20`); + const allFiles = run(["ls-files", "--", `${safeArea}*`]); + if (allFiles && !allFiles.startsWith("[")) { + return allFiles.split("\n").slice(0, 20).join("\n"); + } + return getDiffFiles("HEAD~3"); } // Search for area keyword in git-tracked file paths - const files = run(`git ls-files 2>/dev/null | grep -i '${safeArea}' | head -20`); - if (files && !files.startsWith("[command failed")) return files; + const allFiles = run(["ls-files"]); + if (allFiles && !allFiles.startsWith("[")) { + const matched = allFiles.split("\n").filter(f => f.toLowerCase().includes(safeArea.toLowerCase())).slice(0, 20); + if (matched.length > 0) return matched.join("\n"); + } // Fallback to recently changed files return getDiffFiles("HEAD~3"); @@ -42,18 +50,23 @@ function findAreaFiles(area: string): string { /** Find related test files for an area */ function findRelatedTests(area: string): string { - if (!area) return run("git ls-files 2>/dev/null | grep -E '\\.(spec|test)\\.(ts|tsx|js|jsx)$' | head -10"); + const allFiles = run(["ls-files"]); + if (!allFiles || allFiles.startsWith("[")) return ""; + const testPattern = /\.(spec|test)\.(ts|tsx|js|jsx)$/; + const testFiles = allFiles.split("\n").filter(f => testPattern.test(f)); + + if (!area) return testFiles.slice(0, 10).join("\n"); - const safeArea = shellEscape(area.split(/\s+/)[0]); - const tests = run(`git ls-files 2>/dev/null | grep -E '\\.(spec|test)\\.(ts|tsx|js|jsx)$' | grep -i '${safeArea}' | head -10`); - return tests || run("git ls-files 2>/dev/null | grep -E '\\.(spec|test)\\.(ts|tsx|js|jsx)$' | head -10"); + const safeArea = shellEscape(area.split(/\s+/)[0]).toLowerCase(); + const areaTests = testFiles.filter(f => f.toLowerCase().includes(safeArea)).slice(0, 10); + return areaTests.length > 0 ? areaTests.join("\n") : testFiles.slice(0, 10).join("\n"); } /** Get an example pattern from the first matching file */ function getExamplePattern(files: string): string { const firstFile = files.split("\n").filter(Boolean)[0]; if (!firstFile) return "no pattern available"; - return run(`head -30 '${shellEscape(firstFile)}' 2>/dev/null || echo 'could not read file'`); + return headLines(resolve(PROJECT_DIR, firstFile), 30); } // --------------------------------------------------------------------------- diff --git a/src/tools/sequence-tasks.ts b/src/tools/sequence-tasks.ts index 22dea23..eeed3ff 100644 --- a/src/tools/sequence-tasks.ts +++ b/src/tools/sequence-tasks.ts @@ -90,7 +90,10 @@ export function registerSequenceTasks(server: McpServer): void { // For locality: infer directories from path-like tokens in task text if (strategy === "locality") { // Use git ls-files with a depth limit instead of find for performance - const gitFiles = run("git ls-files 2>/dev/null | head -1000"); + const gitFilesRaw = run(["ls-files"]); + const gitFiles = gitFilesRaw && !gitFilesRaw.startsWith("[") + ? gitFilesRaw.split("\n").slice(0, 1000).join("\n") + : ""; const knownDirs = new Set(); for (const f of gitFiles.split("\n").filter(Boolean)) { const parts = f.split("/"); diff --git a/src/tools/session-handoff.ts b/src/tools/session-handoff.ts index d199462..e97a830 100644 --- a/src/tools/session-handoff.ts +++ b/src/tools/session-handoff.ts @@ -2,14 +2,14 @@ import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { existsSync, readFileSync } from "fs"; import { join } from "path"; -import { run, getBranch, getRecentCommits, getStatus } from "../lib/git.js"; +import { run, exec, getBranch, getRecentCommits, getStatus } from "../lib/git.js"; import { readIfExists, findWorkspaceDocs } from "../lib/files.js"; import { STATE_DIR, now } from "../lib/state.js"; /** Check if a CLI tool is available */ function hasCommand(cmd: string): boolean { - const result = run(`command -v ${cmd} 2>/dev/null`); - return !!result && !result.startsWith("[command failed"); + const result = exec("which", [cmd]); + return !!result && !result.startsWith("["); } export function registerSessionHandoff(server: McpServer): void { @@ -44,7 +44,7 @@ export function registerSessionHandoff(server: McpServer): void { // Only try gh if it exists if (hasCommand("gh")) { - const openPRs = run("gh pr list --state open --json number,title,headRefName 2>/dev/null || echo '[]'"); + const openPRs = exec("gh", ["pr", "list", "--state", "open", "--json", "number,title,headRefName"]); if (openPRs && openPRs !== "[]") { sections.push(`## Open PRs\n\`\`\`json\n${openPRs}\n\`\`\``); } diff --git a/src/tools/sharpen-followup.ts b/src/tools/sharpen-followup.ts index db5acaa..a65f114 100644 --- a/src/tools/sharpen-followup.ts +++ b/src/tools/sharpen-followup.ts @@ -27,15 +27,15 @@ function parsePortelainFiles(output: string): string[] { /** Get recently changed files, safe for first commit / shallow clones */ function getRecentChangedFiles(): string[] { // Try HEAD~1..HEAD, fall back to just staged, then unstaged - const commands = [ - "git diff --name-only HEAD~1 HEAD 2>/dev/null", - "git diff --name-only --cached 2>/dev/null", - "git diff --name-only 2>/dev/null", + const commands: string[][] = [ + ["diff", "--name-only", "HEAD~1", "HEAD"], + ["diff", "--name-only", "--cached"], + ["diff", "--name-only"], ]; const results = new Set(); - for (const cmd of commands) { - const out = run(cmd); - if (out) out.split("\n").filter(Boolean).forEach((f) => results.add(f)); + for (const args of commands) { + const out = run(args); + if (out && !out.startsWith("[")) out.split("\n").filter(Boolean).forEach((f) => results.add(f)); if (results.size > 0) break; // first successful source is enough } return [...results]; @@ -87,7 +87,7 @@ export function registerSharpenFollowup(server: McpServer): void { // Gather context to resolve ambiguity const contextFiles: string[] = [...(previous_files ?? [])]; const recentChanged = getRecentChangedFiles(); - const porcelainOutput = run("git status --porcelain 2>/dev/null"); + const porcelainOutput = run(["status", "--porcelain"]); const untrackedOrModified = parsePortelainFiles(porcelainOutput); const allKnownFiles = [...new Set([...contextFiles, ...recentChanged, ...untrackedOrModified])].filter(Boolean); diff --git a/src/tools/token-audit.ts b/src/tools/token-audit.ts index b7aad2c..0c67ea8 100644 --- a/src/tools/token-audit.ts +++ b/src/tools/token-audit.ts @@ -1,17 +1,12 @@ // CATEGORY 5: token_audit — Token Efficiency import { z } from "zod"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { run } from "../lib/git.js"; +import { run, countLines, countBytes } from "../lib/git.js"; import { readIfExists, findWorkspaceDocs, PROJECT_DIR } from "../lib/files.js"; import { loadState, saveState, now, STATE_DIR } from "../lib/state.js"; import { readFileSync, existsSync, statSync } from "fs"; import { join } from "path"; -/** Shell-escape a filename for safe interpolation */ -function shellEscape(s: string): string { - return s.replace(/'/g, "'\\''"); -} - /** * Grade thresholds rationale: * - A (0-10): Minimal waste — small diffs, targeted reads, lean context @@ -39,8 +34,8 @@ export function registerTokenAudit(server: McpServer): void { let wasteScore = 0; // 1. Git diff size & dirty file count - const diffStat = run("git diff --stat --no-color 2>/dev/null"); - const dirtyFiles = run("git diff --name-only 2>/dev/null"); + const diffStat = run(["diff", "--stat", "--no-color"]); + const dirtyFiles = run(["diff", "--name-only"]); const dirtyList = dirtyFiles.split("\n").filter(Boolean); const dirtyCount = dirtyList.length; @@ -63,8 +58,7 @@ export function registerTokenAudit(server: McpServer): void { for (const f of dirtyList.slice(0, 30)) { // Use shell-safe quoting instead of interpolation - const wc = run(`wc -l < '${shellEscape(f)}' 2>/dev/null`); - const lines = parseInt(wc) || 0; + const lines = countLines(join(PROJECT_DIR, f)); estimatedContextTokens += lines * AVG_LINE_BYTES * AVG_TOKENS_PER_BYTE; if (lines > 500) { largeFiles.push(`${f} (${lines} lines)`); @@ -80,8 +74,7 @@ export function registerTokenAudit(server: McpServer): void { // 3. CLAUDE.md bloat check const claudeMd = readIfExists("CLAUDE.md", 1); if (claudeMd !== null) { - const stat = run(`wc -c < '${shellEscape("CLAUDE.md")}' 2>/dev/null`); - const bytes = parseInt(stat) || 0; + const bytes = countBytes(join(PROJECT_DIR, "CLAUDE.md")); if (bytes > 5120) { patterns.push(`CLAUDE.md is ${(bytes / 1024).toFixed(1)}KB — injected every session, burns tokens on paste`); recommendations.push("Trim CLAUDE.md to essentials (<5KB). Move reference docs to files read on-demand"); @@ -139,7 +132,7 @@ export function registerTokenAudit(server: McpServer): void { // Read with size cap: take the tail if too large const raw = stat.size <= MAX_TOOL_LOG_BYTES ? readFileSync(toolLogPath, "utf-8") - : run(`tail -c ${MAX_TOOL_LOG_BYTES} '${shellEscape(toolLogPath)}'`); + : (() => { try { const buf = readFileSync(toolLogPath, "utf-8"); return buf.slice(-MAX_TOOL_LOG_BYTES); } catch { return ""; } })(); const lines = raw.trim().split("\n").filter(Boolean); totalToolCalls = lines.length;